1use std::collections::{BTreeMap, BTreeSet};
61use std::io::{Cursor, Read, Write};
62use std::path::{Path, PathBuf};
63use std::time::{SystemTime, UNIX_EPOCH};
64
65use base64::{
66 engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
67 Engine as _,
68};
69use ring::signature::{UnparsedPublicKey, ED25519};
70use serde::{Deserialize, Serialize};
71use serde_json::{json, Value};
72use sha2::{Digest, Sha256};
73
74use crate::store::Store;
75
76pub const HUB_URL_ENV: &str = "DBMD_HUB_URL";
78
79pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
82
83pub const HUB_CREDENTIAL_ORIGIN_ENV: &str = "DBMD_HUB_CREDENTIAL_ORIGIN";
87
88pub const STATE_DIR_ENV: &str = "DBMD_STATE_DIR";
92
93pub const ALLOW_PRIVATE_REGISTRY_HOME_ENV: &str = "DBMD_ALLOW_PRIVATE_REGISTRY_HOME";
97
98pub const ALLOW_PRIVATE_OBJECT_URL_ENV: &str = "DBMD_ALLOW_PRIVATE_OBJECT_URL";
102
103pub const BRAIN_KEY_FILE_ENV: &str = "DBMD_BRAIN_KEY_FILE";
109
110pub const AGENT_KEY_FILE_ENV: &str = "DBMD_AGENT_KEY_FILE";
118
119pub const CONFIG_REL_PATH: &str = ".dbmd/config";
122
123const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024;
126const MAX_FEED_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
129const MAX_REGISTRY_CARD_BYTES: u64 = 1024 * 1024;
131
132const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
135const MAX_STAGED_CHANGE_BYTES: usize = 64 * 1024 * 1024;
138
139const MAX_PUSH_FILES: usize = u16::MAX as usize;
141const MAX_STORE_PATH_BYTES: usize = 1_024;
142const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
143const MAX_PACK_BYTES: u64 =
146 MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
147const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
156const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
157
158const MAX_IDENTITY_ROTATIONS: usize = 1_024;
161
162fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
165 let mut batches: Vec<Vec<Value>> = Vec::new();
166 let mut current: Vec<Value> = Vec::new();
167 let mut current_bytes = 0usize;
168 for declaration in declarations {
169 let declared_bytes = serde_json::to_string(&declaration)
170 .map(|text| text.len())
171 .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
172 + 1;
173 if !current.is_empty()
174 && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
175 || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
176 {
177 batches.push(std::mem::take(&mut current));
178 current_bytes = 0;
179 }
180 current_bytes += declared_bytes;
181 current.push(declaration);
182 }
183 if !current.is_empty() {
184 batches.push(current);
185 }
186 batches
187}
188const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
192const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
193const FEED_PAGE_LIMIT: usize = 100;
194
195pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
200
201const CONNECT_TIMEOUT_SECS: u64 = 10;
204const READ_TIMEOUT_SECS: u64 = 120;
205const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
209const COMMIT_REQUEST_TIMEOUT_SECS: u64 = 900;
216const COMMIT_ATTEMPTS: usize = 4;
220const COMMIT_RETRY_BACKOFF_MS: [u64; COMMIT_ATTEMPTS - 1] = [5_000, 20_000, 45_000];
221const CONNECT_ATTEMPTS: usize = 3;
222const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
223
224const UPLOAD_ATTEMPTS: usize = 6;
228const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
229
230fn upload_retry_backoff_ms(attempt: usize) -> u64 {
231 UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
232}
233
234const RESERVATION_ATTEMPTS: usize = 7;
239const RESERVATION_BACKOFF_MS: [u64; RESERVATION_ATTEMPTS - 1] =
240 [500, 2_000, 5_000, 15_000, 30_000, 60_000];
241
242fn is_retryable_hub_status(status: u16) -> bool {
246 matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
247}
248
249fn is_retryable_upload_status(status: u16) -> bool {
253 matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
254}
255const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
259#[cfg(unix)]
263const V2_PULL_INSTALL_WORKERS: usize = 16;
264const V2_BULK_STREAM_FILES: usize = 256;
268const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
269const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
270const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
271
272#[derive(Debug, thiserror::Error)]
276pub enum LinkError {
277 #[error(
279 "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
280 )]
281 NoHub,
282
283 #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
285 NoCredential,
286
287 #[error(
290 "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
291 )]
292 BadKey,
293
294 #[error(
300 "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}"
301 )]
302 UnboundCredential,
303
304 #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
308 BadAgentKey {
309 message: String,
311 },
312
313 #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
315 UnsafeHub {
316 hub: String,
318 },
319
320 #[error("hub unreachable at {hub}: {message}")]
322 Transport {
323 hub: String,
325 message: String,
327 },
328
329 #[error("{what} failed (HTTP {status}): {message}")]
331 Http {
332 what: &'static str,
334 status: u16,
336 message: String,
338 code: Option<String>,
340 details: Option<Value>,
342 },
343
344 #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
347 NotJson {
348 what: &'static str,
350 status: u16,
352 },
353
354 #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
356 ResponseTooLarge {
357 limit_bytes: u64,
359 },
360
361 #[error("invalid address `{given}`: {reason}")]
363 BadAddress {
364 given: String,
366 reason: String,
368 },
369
370 #[error(
372 "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
373 )]
374 BadGrantId {
375 given: String,
377 },
378
379 #[error("refusing unsafe path from the hub: `{path}`")]
383 UnsafePath {
384 path: String,
386 },
387
388 #[error(
390 "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
391 MAX_STORE_BYTES / (1024 * 1024),
392 MAX_PACK_BYTES / (1024 * 1024)
393 )]
394 PushTooLarge {
395 detail: String,
397 },
398
399 #[error(
401 "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
402 MAX_PROPOSE_BYTES / 1024
403 )]
404 ProposeTooLarge {
405 bytes: u64,
407 },
408
409 #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
411 NotUtf8 {
412 path: String,
414 },
415
416 #[error("invalid store pack: {message}")]
418 InvalidPack {
419 message: String,
421 },
422
423 #[error("invalid signed feed: {message}")]
425 InvalidFeed {
426 message: String,
428 },
429
430 #[error(
434 "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}`"
435 )]
436 AliasRebindRequired {
437 alias: String,
438 from: String,
439 to: String,
440 },
441
442 #[error("sync conflict on {paths:?} — resolve the named files and retry")]
445 Conflict {
446 paths: Vec<String>,
448 },
449
450 #[error(
454 "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
455 )]
456 ConflictBundle {
457 bundle: String,
459 paths: Vec<String>,
461 },
462
463 #[error(
467 "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
468 )]
469 LocalPolicyTransition {
470 paths: Vec<String>,
472 },
473
474 #[error(
479 "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
480 )]
481 BulkPreviewRequired {
482 preview: Value,
484 },
485
486 #[error(
489 "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
490 )]
491 ScopedProjectionModified,
492
493 #[error(
497 "the checkout's permission scope changed — clone into a new directory to accept the new view"
498 )]
499 ScopedViewChanged,
500
501 #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
504 BrainUnavailable,
505
506 #[error(
509 "the remote brain advanced during sync — retry to converge from the new verified head"
510 )]
511 RemoteAdvancedDuringSync,
512
513 #[error(
516 "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
517 )]
518 UnsupportedPlatform {
519 operation: &'static str,
521 },
522
523 #[error(transparent)]
525 Io(#[from] std::io::Error),
526
527 #[error(transparent)]
529 Store(#[from] crate::StoreError),
530}
531
532pub type LinkResult<T> = std::result::Result<T, LinkError>;
534
535#[derive(Debug, Clone, PartialEq, Eq)]
537pub struct V2BulkConfirmation {
538 pub id: String,
540 pub digest: String,
543}
544
545impl V2BulkConfirmation {
546 pub fn parse(value: &str) -> LinkResult<Self> {
549 let (id, digest) = value
550 .split_once(':')
551 .ok_or_else(|| LinkError::InvalidPack {
552 message: "bulk confirmation must be <id>:<digest>".to_string(),
553 })?;
554 if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
555 return Err(LinkError::InvalidPack {
556 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
557 .to_string(),
558 });
559 }
560 Ok(Self {
561 id: id.to_string(),
562 digest: digest.to_string(),
563 })
564 }
565}
566
567fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
572 #[cfg(any(target_os = "linux", target_os = "macos", windows))]
573 {
574 let _ = operation;
575 Ok(())
576 }
577 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
578 {
579 Err(LinkError::UnsupportedPlatform { operation })
580 }
581}
582
583#[derive(Debug, Clone, PartialEq, Eq)]
589pub enum AddressTarget {
590 Id(String),
592 Path(String),
596}
597
598const BAD_BRAIN_REASON: &str =
601 "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
602
603const BAD_TARGET_REASON: &str =
606 "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
607
608#[derive(Debug, Clone, PartialEq, Eq)]
613pub struct Address {
614 pub brain: String,
616 pub target: Option<AddressTarget>,
618}
619
620impl Address {
621 pub fn parse(raw: &str) -> LinkResult<Address> {
625 let bad = |reason: &str| LinkError::BadAddress {
626 given: raw.to_string(),
627 reason: reason.to_string(),
628 };
629
630 let trimmed = raw.trim();
631 let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
632 if body.is_empty() {
633 return Err(bad("empty address"));
634 }
635
636 let (brain, rest) = match body.split_once('/') {
637 Some((b, r)) => (b, Some(r)),
638 None => (body, None),
639 };
640
641 if brain.is_empty() {
642 return Err(bad("missing brain reference before `/`"));
643 }
644 if !is_safe_ref(brain) {
645 return Err(bad(BAD_BRAIN_REASON));
646 }
647
648 let target = match rest {
649 None => None,
650 Some("") => return Err(bad("trailing `/` with no record id or path")),
651 Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
652 Some(r) => {
653 if !safe_store_rel_path(r) || !r.ends_with(".md") {
654 return Err(bad(BAD_TARGET_REASON));
655 }
656 Some(AddressTarget::Path(r.to_string()))
657 }
658 };
659
660 Ok(Address {
661 brain: brain.to_string(),
662 target,
663 })
664 }
665}
666
667fn is_safe_ref(s: &str) -> bool {
670 !s.is_empty()
671 && s.len() <= 64
672 && s.bytes()
673 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
674}
675
676pub fn is_valid_handle(s: &str) -> bool {
679 is_safe_ref(s)
680}
681
682pub fn safe_store_rel_path(p: &str) -> bool {
688 if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
689 return false;
690 }
691 if !p
692 .bytes()
693 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
694 {
695 return false;
696 }
697 p.split('/')
698 .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
699}
700
701fn require_safe_ref(brain: &str) -> LinkResult<()> {
709 if is_safe_ref(brain) {
710 Ok(())
711 } else {
712 Err(LinkError::BadAddress {
713 given: brain.to_string(),
714 reason: BAD_BRAIN_REASON.to_string(),
715 })
716 }
717}
718
719fn require_valid_handle(handle: &str) -> LinkResult<()> {
721 if is_valid_handle(handle) {
722 Ok(())
723 } else {
724 Err(LinkError::BadAddress {
725 given: handle.to_string(),
726 reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
727 })
728 }
729}
730
731fn require_safe_grant_id(id: &str) -> LinkResult<()> {
735 if is_safe_ref(id) {
736 Ok(())
737 } else {
738 Err(LinkError::BadGrantId {
739 given: id.to_string(),
740 })
741 }
742}
743
744#[derive(Debug, Clone)]
750pub struct HubConfig {
751 pub hub: String,
753 pub key: Option<String>,
755 pub agent_key: Option<AgentSigningKey>,
758 pub brain_key: Option<AgentSigningKey>,
761 pub state_dir: PathBuf,
764 store_selected: bool,
767}
768
769#[derive(Clone)]
772pub struct AgentSigningKey {
773 pkcs8: Vec<u8>,
774 pub multikey: String,
776 pub public_key_spki: String,
778}
779
780impl std::fmt::Debug for AgentSigningKey {
781 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
782 f.debug_struct("AgentSigningKey")
783 .field("multikey", &self.multikey)
784 .field("pkcs8", &"<redacted>")
785 .finish()
786 }
787}
788
789impl HubConfig {
790 pub fn require_key(&self) -> LinkResult<&str> {
793 self.key.as_deref().ok_or(LinkError::NoCredential)
794 }
795}
796
797pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
802 let explicit_hub = flag_hub
803 .map(str::to_string)
804 .or_else(|| env_nonempty(HUB_URL_ENV));
805 let selected_by_store = explicit_hub.is_none();
806 let hub = explicit_hub
807 .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
808 .ok_or(LinkError::NoHub)?;
809 let hub = hub.trim().trim_end_matches('/').to_string();
810 assert_safe_hub(&hub)?;
811 if selected_by_store {
812 let parsed =
813 url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
814 if !parsed.scheme().eq_ignore_ascii_case("https")
818 || (parsed.path() != "/" && !parsed.path().is_empty())
819 {
820 return Err(LinkError::UnsafeHub { hub });
821 }
822 }
823
824 let key = match env_nonempty(HUB_KEY_ENV) {
825 Some(raw) => Some(clean_key(&raw)?),
826 None => None,
827 };
828
829 let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
830 Some(path) => Some(load_agent_key(Path::new(&path))?),
831 None => None,
832 };
833
834 let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
835 Some(path) => Some(load_agent_key(Path::new(&path))?),
836 None => None,
837 };
838
839 if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
846 let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
847 .and_then(|value| normalized_origin(&value).ok());
848 let selected_origin = normalized_origin(&hub)?;
849 if bound.as_deref() != Some(selected_origin.as_str()) {
850 return Err(LinkError::UnboundCredential);
851 }
852 }
853
854 Ok(HubConfig {
855 hub,
856 key,
857 agent_key,
858 brain_key,
859 state_dir: toolkit_state_dir()?,
860 store_selected: selected_by_store,
861 })
862}
863
864fn toolkit_state_dir() -> LinkResult<PathBuf> {
865 if let Some(path) = env_nonempty(STATE_DIR_ENV) {
866 let path = PathBuf::from(path);
867 if !path.is_absolute() {
868 return Err(LinkError::UnsafePath {
869 path: path.display().to_string(),
870 });
871 }
872 return Ok(path);
873 }
874 #[cfg(windows)]
875 if let Some(base) = env_nonempty("LOCALAPPDATA") {
876 let base = PathBuf::from(base);
877 if base.is_absolute() {
878 return Ok(base.join("dbmd").join("state"));
879 }
880 }
881 #[cfg(windows)]
882 {
883 Err(LinkError::Io(std::io::Error::new(
884 std::io::ErrorKind::NotFound,
885 format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
886 )))
887 }
888 #[cfg(not(windows))]
889 if let Some(base) = env_nonempty("XDG_STATE_HOME") {
890 let base = PathBuf::from(base);
891 if base.is_absolute() {
892 return Ok(base.join("dbmd"));
893 }
894 }
895 #[cfg(not(windows))]
896 let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
897 LinkError::Io(std::io::Error::new(
898 std::io::ErrorKind::NotFound,
899 format!("cannot locate user state; set {STATE_DIR_ENV}"),
900 ))
901 })?);
902 #[cfg(not(windows))]
903 if !home.is_absolute() {
904 return Err(LinkError::UnsafePath {
905 path: home.display().to_string(),
906 });
907 }
908 #[cfg(target_os = "macos")]
909 {
910 Ok(home
911 .join("Library")
912 .join("Application Support")
913 .join("dbmd")
914 .join("state"))
915 }
916 #[cfg(all(not(target_os = "macos"), not(windows)))]
917 {
918 Ok(home.join(".local").join("state").join("dbmd"))
919 }
920}
921
922fn normalized_origin(value: &str) -> LinkResult<String> {
923 let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
924 hub: value.to_string(),
925 })?;
926 if !(parsed.scheme().eq_ignore_ascii_case("https")
927 || parsed.scheme().eq_ignore_ascii_case("http"))
928 || !parsed.username().is_empty()
929 || parsed.password().is_some()
930 || (parsed.path() != "/" && !parsed.path().is_empty())
931 || parsed.query().is_some()
932 || parsed.fragment().is_some()
933 {
934 return Err(LinkError::UnsafeHub {
935 hub: value.to_string(),
936 });
937 }
938 let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
939 hub: value.to_string(),
940 })?;
941 let host = if host.contains(':') {
942 format!("[{host}]")
943 } else {
944 host.to_ascii_lowercase()
945 };
946 let port = parsed
947 .port_or_known_default()
948 .ok_or_else(|| LinkError::UnsafeHub {
949 hub: value.to_string(),
950 })?;
951 let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
952 || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
953 Ok(format!(
954 "{}://{}{}",
955 parsed.scheme().to_ascii_lowercase(),
956 host,
957 if default {
958 String::new()
959 } else {
960 format!(":{port}")
961 }
962 ))
963}
964
965const ED25519_SPKI_PREFIX: [u8; 12] = [
972 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
973];
974
975fn bad_agent_key(message: &str) -> LinkError {
976 LinkError::BadAgentKey {
977 message: message.to_string(),
978 }
979}
980
981fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
982 ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
986 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
987 .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
988}
989
990fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
992 use ring::signature::KeyPair as _;
993 let mut spki = Vec::with_capacity(44);
994 spki.extend_from_slice(&ED25519_SPKI_PREFIX);
995 spki.extend_from_slice(pair.public_key().as_ref());
996 (
997 URL_SAFE_NO_PAD.encode(&spki),
998 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
999 )
1000}
1001
1002pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1006 load_agent_key(path)
1007}
1008
1009fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1011 #[cfg(unix)]
1012 let file = {
1013 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1014 use std::os::unix::ffi::OsStrExt as _;
1015 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1016 .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1017 let leaf = path
1018 .file_name()
1019 .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1020 let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1021 let fd = unsafe {
1022 libc::openat(
1023 parent.as_raw_fd(),
1024 leaf.as_ptr(),
1025 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1026 )
1027 };
1028 if fd < 0 {
1029 return Err(bad_agent_key(
1030 "the key path must be an existing regular file without symlink ancestors",
1031 ));
1032 }
1033 unsafe { std::fs::File::from_raw_fd(fd) }
1034 };
1035 #[cfg(not(unix))]
1036 let file = std::fs::File::open(path)
1037 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1038 let metadata = file
1039 .metadata()
1040 .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1041 if !metadata.is_file() {
1042 return Err(bad_agent_key("the key path must be a regular file"));
1043 }
1044 #[cfg(unix)]
1045 {
1046 use std::os::unix::fs::PermissionsExt as _;
1047 if metadata.permissions().mode() & 0o077 != 0 {
1048 return Err(bad_agent_key(
1049 "the key file is accessible to group/other; set mode 0600",
1050 ));
1051 }
1052 }
1053 let mut text = String::new();
1054 file.take(1024 * 1024 + 1)
1055 .read_to_string(&mut text)
1056 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1057 if text.len() > 1024 * 1024 {
1058 return Err(bad_agent_key("the key file exceeds the size limit"));
1059 }
1060 let pkcs8 = URL_SAFE_NO_PAD
1061 .decode(text.trim())
1062 .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1063 let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1064 Ok(AgentSigningKey {
1065 pkcs8,
1066 multikey,
1067 public_key_spki,
1068 })
1069}
1070
1071fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1077 #[cfg(unix)]
1078 let (mut file, parent, leaf) = {
1079 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1080 use std::os::unix::ffi::OsStrExt as _;
1081 let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1082 let leaf_name = path
1083 .file_name()
1084 .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1085 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1086 let fd = unsafe {
1087 libc::openat(
1088 parent.as_raw_fd(),
1089 leaf.as_ptr(),
1090 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1091 0o600,
1092 )
1093 };
1094 if fd < 0 {
1095 let error = std::io::Error::last_os_error();
1096 if error.kind() == std::io::ErrorKind::AlreadyExists {
1097 return Err(bad_agent_key(
1098 "the output file already exists — refusing to overwrite a key",
1099 ));
1100 }
1101 return Err(error.into());
1102 }
1103 (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1104 };
1105 #[cfg(not(unix))]
1106 let mut file = std::fs::OpenOptions::new()
1107 .write(true)
1108 .create_new(true)
1109 .open(path)
1110 .map_err(|error| {
1111 if error.kind() == std::io::ErrorKind::AlreadyExists {
1112 bad_agent_key("the output file already exists — refusing to overwrite a key")
1113 } else {
1114 LinkError::Io(error)
1115 }
1116 })?;
1117 if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1118 drop(file);
1119 #[cfg(unix)]
1120 let _ =
1121 unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1122 #[cfg(not(unix))]
1123 let _ = std::fs::remove_file(path);
1124 return Err(LinkError::Io(error));
1125 }
1126 drop(file);
1127 #[cfg(unix)]
1128 parent.sync_all()?;
1129 Ok(())
1130}
1131
1132#[derive(Debug, Serialize)]
1135pub struct GeneratedAgentKey {
1136 pub multikey: String,
1138 #[serde(rename = "publicKeySpki")]
1140 pub public_key_spki: String,
1141 #[serde(rename = "keyFile")]
1143 pub key_file: String,
1144}
1145
1146pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1151 require_hardened_filesystem("key generation")?;
1152 let rng = ring::rand::SystemRandom::new();
1153 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1154 .map_err(|_| bad_agent_key("key generation failed"))?;
1155 let pair = agent_keypair(pkcs8.as_ref())?;
1156 let (spki_b64u, multikey) = public_identity_for(&pair);
1157
1158 write_secret_new(
1159 out,
1160 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1161 )?;
1162
1163 Ok(GeneratedAgentKey {
1164 multikey,
1165 public_key_spki: spki_b64u,
1166 key_file: out.display().to_string(),
1167 })
1168}
1169
1170fn linkmd_sig_header(
1179 key: &AgentSigningKey,
1180 origin: &str,
1181 method: &str,
1182 path: &str,
1183 body: Option<&str>,
1184) -> LinkResult<String> {
1185 let ts = std::time::SystemTime::now()
1186 .duration_since(std::time::UNIX_EPOCH)
1187 .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1188 .as_secs();
1189 let body_hash = match body {
1190 Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1191 None => "-".to_string(),
1192 };
1193 let canonical = format!(
1194 "v2\n{}\n{}\n{}\n{}\n{}",
1195 origin,
1196 method.to_uppercase(),
1197 path,
1198 ts,
1199 body_hash
1200 );
1201 let pair = agent_keypair(&key.pkcs8)?;
1202 let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1203 let fingerprint = key.multikey.trim_start_matches("ed25519:");
1204 Ok(format!(
1205 "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1206 ))
1207}
1208
1209#[derive(Serialize)]
1216struct WireFeedFile {
1217 path: String,
1218 sha256: String,
1219 bytes: u64,
1220}
1221
1222#[derive(Serialize)]
1225struct UnsignedWireEntry<'a> {
1226 v: u8,
1227 seq: u64,
1228 ts: String,
1229 brain: &'a str,
1230 public_key: &'a str,
1231 kind: &'a str,
1232 op: &'a str,
1233 pack_sha256: &'a str,
1234 files: &'a [WireFeedFile],
1235 removed: &'a [String],
1236 prev_entry_hash: Option<&'a str>,
1237}
1238
1239fn self_custody_entry(
1245 key: &AgentSigningKey,
1246 seq: u64,
1247 ts: String,
1248 pack_sha256: &str,
1249 files: &[WireFeedFile],
1250 prev_entry_hash: Option<&str>,
1251) -> LinkResult<String> {
1252 let removed: [String; 0] = [];
1253 let unsigned = serde_json::to_string(&UnsignedWireEntry {
1254 v: 1,
1255 seq,
1256 ts,
1257 brain: &key.multikey,
1258 public_key: &key.public_key_spki,
1259 kind: "push",
1260 op: "snapshot",
1261 pack_sha256,
1262 files,
1263 removed: &removed,
1264 prev_entry_hash,
1265 })
1266 .expect("serialize feed entry");
1267 let pair = agent_keypair(&key.pkcs8)?;
1268 let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1269 Ok(format!(
1270 "{},\"sig\":\"{}\"}}",
1271 &unsigned[..unsigned.len() - 1],
1272 sig
1273 ))
1274}
1275
1276fn env_nonempty(name: &str) -> Option<String> {
1279 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1280}
1281
1282fn config_file_hub(path: &Path) -> Option<String> {
1287 const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1288 #[cfg(unix)]
1289 let file = {
1290 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1291 use std::os::unix::ffi::OsStrExt as _;
1292 let parent =
1293 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1294 let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1295 let fd = unsafe {
1296 libc::openat(
1297 parent.as_raw_fd(),
1298 leaf.as_ptr(),
1299 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1300 )
1301 };
1302 if fd < 0 {
1303 return None;
1304 }
1305 unsafe { std::fs::File::from_raw_fd(fd) }
1306 };
1307 #[cfg(not(unix))]
1308 let file = std::fs::File::open(path).ok()?;
1309 let metadata = file.metadata().ok()?;
1310 if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1311 return None;
1312 }
1313 let mut bytes = Vec::with_capacity(metadata.len() as usize);
1314 file.take(MAX_CONFIG_BYTES + 1)
1315 .read_to_end(&mut bytes)
1316 .ok()?;
1317 if bytes.len() as u64 > MAX_CONFIG_BYTES {
1318 return None;
1319 }
1320 let text = String::from_utf8(bytes).ok()?;
1321 for line in text.lines() {
1322 let line = line.trim();
1323 if line.is_empty() || line.starts_with('#') {
1324 continue;
1325 }
1326 if let Some((k, v)) = line.split_once('=') {
1327 if k.trim() == "hub" {
1328 let v = v.trim();
1329 if !v.is_empty() {
1330 return Some(v.to_string());
1331 }
1332 }
1333 }
1334 }
1335 None
1336}
1337
1338fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1341 let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1342 hub: hub.to_string(),
1343 })?;
1344 if !(parsed.scheme().eq_ignore_ascii_case("https")
1345 || parsed.scheme().eq_ignore_ascii_case("http"))
1346 || !parsed.username().is_empty()
1347 || parsed.password().is_some()
1348 || (parsed.path() != "/" && !parsed.path().is_empty())
1349 || parsed.query().is_some()
1350 || parsed.fragment().is_some()
1351 {
1352 return Err(LinkError::UnsafeHub {
1353 hub: hub.to_string(),
1354 });
1355 }
1356 let loopback = match parsed.host() {
1357 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1358 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1359 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1360 None => false,
1361 };
1362 if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1363 Ok(())
1364 } else {
1365 Err(LinkError::UnsafeHub {
1366 hub: hub.to_string(),
1367 })
1368 }
1369}
1370
1371fn clean_key(raw: &str) -> LinkResult<String> {
1376 let k = raw.trim();
1377 if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1378 return Err(LinkError::BadKey);
1379 }
1380 Ok(k.to_string())
1381}
1382
1383#[derive(Debug)]
1389pub struct HubResponse {
1390 pub status: u16,
1392 pub body: Option<Value>,
1394}
1395
1396struct RawHubResponse {
1397 status: u16,
1398 body: Vec<u8>,
1399}
1400
1401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1403enum Auth {
1404 Required,
1406 None,
1408 Optional,
1412}
1413
1414fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1415 ureq::AgentBuilder::new()
1416 .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1417 .redirects(0)
1421 .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1422 .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1423 .timeout_write(overall)
1424 .timeout(overall)
1425}
1426
1427fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1428 hub_agent_with_timeout(
1429 cfg,
1430 std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1431 )
1432}
1433
1434fn hub_agent_with_timeout(
1435 cfg: &HubConfig,
1436 overall: std::time::Duration,
1437) -> LinkResult<ureq::Agent> {
1438 if !cfg.store_selected {
1439 return Ok(agent_builder_with_timeout(overall).build());
1440 }
1441 let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1442 hub: cfg.hub.clone(),
1443 })?;
1444 pinned_public_agent_pooled(
1445 &parsed,
1446 false,
1447 "store-selected hub",
1448 AgentShape {
1449 overall,
1450 ..AgentShape::default()
1451 },
1452 )
1453}
1454
1455fn request_raw(
1460 cfg: &HubConfig,
1461 method: &str,
1462 path: &str,
1463 body: Option<&Value>,
1464 auth: Auth,
1465 max_response_bytes: u64,
1466) -> LinkResult<RawHubResponse> {
1467 let http = hub_agent(cfg)?;
1468 request_raw_with_agent(
1469 cfg,
1470 &http,
1471 method,
1472 path,
1473 body,
1474 RawRequestOptions {
1475 auth,
1476 max_response_bytes,
1477 request_id: None,
1478 },
1479 )
1480}
1481
1482struct RawRequestOptions<'a> {
1483 auth: Auth,
1484 max_response_bytes: u64,
1485 request_id: Option<&'a str>,
1486}
1487
1488fn request_raw_with_agent(
1489 cfg: &HubConfig,
1490 http: &ureq::Agent,
1491 method: &str,
1492 path: &str,
1493 body: Option<&Value>,
1494 options: RawRequestOptions<'_>,
1495) -> LinkResult<RawHubResponse> {
1496 let url = format!("{}{}", cfg.hub, path);
1497 let encoded_body = body.map(Value::to_string);
1498 let origin = normalized_origin(&cfg.hub)?;
1499 let credential = match options.auth {
1502 Auth::Required => Some(match &cfg.agent_key {
1503 Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1504 None => format!("Bearer {}", cfg.require_key()?),
1505 }),
1506 Auth::Optional => match &cfg.agent_key {
1507 Some(key) => Some(linkmd_sig_header(
1508 key,
1509 &origin,
1510 method,
1511 path,
1512 encoded_body.as_deref(),
1513 )?),
1514 None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1515 },
1516 Auth::None => None,
1517 };
1518 let result = with_connect_retries(|| {
1519 let mut req = http.request(method, &url);
1520 if let Some(value) = &credential {
1521 req = req.set("authorization", value);
1522 }
1523 if let Some(value) = options.request_id {
1524 req = req.set("x-request-id", value);
1525 }
1526 match &encoded_body {
1527 Some(value) => req
1528 .set("content-type", "application/json")
1529 .send_string(value)
1530 .map_err(Box::new),
1531 None => req.call().map_err(Box::new),
1532 }
1533 });
1534 let resp = match result {
1535 Ok(resp) => resp,
1536 Err(error) => match *error {
1537 ureq::Error::Status(_, resp) => resp,
1538 ureq::Error::Transport(error) => {
1539 return Err(LinkError::Transport {
1540 hub: cfg.hub.clone(),
1541 message: error.to_string(),
1542 });
1543 }
1544 },
1545 };
1546
1547 let status = resp.status();
1548 let buf = read_response_body(resp, options.max_response_bytes + 1, &cfg.hub)?;
1549 if buf.len() as u64 > options.max_response_bytes {
1550 return Err(LinkError::ResponseTooLarge {
1551 limit_bytes: options.max_response_bytes,
1552 });
1553 }
1554 Ok(RawHubResponse { status, body: buf })
1555}
1556
1557fn request_capped(
1558 cfg: &HubConfig,
1559 method: &str,
1560 path: &str,
1561 body: Option<&Value>,
1562 auth: Auth,
1563 max_response_bytes: u64,
1564) -> LinkResult<HubResponse> {
1565 let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1566 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1567 Ok(HubResponse {
1568 status: raw.status,
1569 body: parsed,
1570 })
1571}
1572
1573fn request_patient(
1585 cfg: &HubConfig,
1586 method: &str,
1587 path: &str,
1588 body: Option<&Value>,
1589 auth: Auth,
1590) -> LinkResult<HubResponse> {
1591 let http = hub_agent_with_timeout(
1592 cfg,
1593 std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1594 )?;
1595 let mut attempt = 0;
1596 loop {
1597 let sent = request_raw_with_agent(
1598 cfg,
1599 &http,
1600 method,
1601 path,
1602 body,
1603 RawRequestOptions {
1604 auth,
1605 max_response_bytes: MAX_RESPONSE_BYTES,
1606 request_id: None,
1607 },
1608 );
1609 match sent {
1610 Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1611 std::thread::sleep(std::time::Duration::from_millis(
1612 COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1613 ));
1614 attempt += 1;
1615 }
1616 Err(error) => return Err(error),
1617 Ok(raw) => {
1618 return Ok(HubResponse {
1619 status: raw.status,
1620 body: serde_json::from_slice(&raw.body).ok(),
1621 })
1622 }
1623 }
1624 }
1625}
1626
1627fn request(
1628 cfg: &HubConfig,
1629 method: &str,
1630 path: &str,
1631 body: Option<&Value>,
1632 auth: Auth,
1633) -> LinkResult<HubResponse> {
1634 request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1635}
1636
1637fn request_with_request_id(
1642 cfg: &HubConfig,
1643 method: &str,
1644 path: &str,
1645 body: Option<&Value>,
1646 auth: Auth,
1647 request_id: &str,
1648) -> LinkResult<HubResponse> {
1649 if request_id.is_empty()
1650 || request_id.len() > 128
1651 || !request_id
1652 .bytes()
1653 .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1654 {
1655 return Err(invalid_feed("hub returned an unsafe request id"));
1656 }
1657 let http = hub_agent_with_timeout(
1660 cfg,
1661 std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1662 )?;
1663 let raw = request_raw_with_agent(
1664 cfg,
1665 &http,
1666 method,
1667 path,
1668 body,
1669 RawRequestOptions {
1670 auth,
1671 max_response_bytes: MAX_RESPONSE_BYTES,
1672 request_id: Some(request_id),
1673 },
1674 )?;
1675 Ok(HubResponse {
1676 status: raw.status,
1677 body: serde_json::from_slice(&raw.body).ok(),
1678 })
1679}
1680
1681fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1682 if (200..300).contains(&r.status) {
1683 return Ok(r.body);
1684 }
1685 ensure_ok(
1686 HubResponse {
1687 status: r.status,
1688 body: serde_json::from_slice(&r.body).ok(),
1689 },
1690 what,
1691 )
1692 .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1693}
1694
1695fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1700 matches!(
1701 kind,
1702 ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1703 )
1704}
1705
1706fn with_connect_retries(
1707 mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1708) -> Result<ureq::Response, Box<ureq::Error>> {
1709 let mut attempt = 0;
1710 loop {
1711 match send() {
1712 Err(error)
1713 if matches!(
1714 error.as_ref(),
1715 ureq::Error::Transport(transport)
1716 if is_pre_request_transport(transport.kind())
1717 ) && attempt + 1 < CONNECT_ATTEMPTS =>
1718 {
1719 std::thread::sleep(std::time::Duration::from_millis(
1720 CONNECT_RETRY_BACKOFF_MS[attempt],
1721 ));
1722 attempt += 1;
1723 }
1724 result => return result,
1725 }
1726 }
1727}
1728
1729fn hub_is_loopback(hub: &str) -> bool {
1730 url::Url::parse(hub).ok().is_some_and(|parsed| {
1731 parsed.host().is_some_and(|host| match host {
1732 url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1733 url::Host::Ipv4(ip) => ip.is_loopback(),
1734 url::Host::Ipv6(ip) => ip.is_loopback(),
1735 })
1736 })
1737}
1738
1739fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1743 let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1744 message: "the hub returned an invalid object-store URL".to_string(),
1745 })?;
1746 let allow_private = hub_is_loopback(&cfg.hub)
1747 || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1748 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1749 || !parsed.username().is_empty()
1750 || parsed.password().is_some()
1751 || parsed.fragment().is_some()
1752 {
1753 return Err(LinkError::InvalidPack {
1754 message: "the hub returned an unsafe object-store URL".to_string(),
1755 });
1756 }
1757 Ok((parsed, allow_private))
1758}
1759
1760fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1761 let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1762 pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1763 LinkError::InvalidPack {
1764 message: "the hub returned an object-store URL with an unsafe network target"
1765 .to_string(),
1766 }
1767 })
1768}
1769
1770fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1779 let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1780 let authority = (
1781 first.host_str()?.to_string(),
1782 first.port_or_known_default()?,
1783 );
1784 for raw in &urls[1..] {
1785 let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1786 if (parsed.host_str()?, parsed.port_or_known_default()?)
1787 != (authority.0.as_str(), authority.1)
1788 {
1789 return None;
1790 }
1791 }
1792 pinned_public_agent_pooled(
1793 &first,
1794 allow_private,
1795 "object-store URL",
1796 AgentShape {
1797 idle_per_host: V2_UPLOAD_CONCURRENCY,
1798 ..AgentShape::default()
1799 },
1800 )
1801 .ok()
1802}
1803
1804fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1805 let http = presigned_agent(cfg, raw)?;
1806 let mut attempt = 0;
1807 let result = loop {
1808 let mut req = http.put(raw);
1812 if let Some(map) = headers.as_object() {
1813 for (name, value) in map {
1814 if let Some(value) = value.as_str() {
1815 req = req.set(name, value);
1816 }
1817 }
1818 }
1819 match req.send_bytes(bytes) {
1820 Err(ureq::Error::Transport(_)) if attempt + 1 < UPLOAD_ATTEMPTS => {
1826 std::thread::sleep(std::time::Duration::from_millis(upload_retry_backoff_ms(
1827 attempt,
1828 )));
1829 attempt += 1;
1830 }
1831 Err(ureq::Error::Status(status, _))
1832 if status != 412
1833 && is_retryable_upload_status(status)
1834 && attempt + 1 < UPLOAD_ATTEMPTS =>
1835 {
1836 std::thread::sleep(std::time::Duration::from_millis(upload_retry_backoff_ms(
1837 attempt,
1838 )));
1839 attempt += 1;
1840 }
1841 result => break result,
1842 }
1843 };
1844 match result {
1845 Ok(resp) if (200..300).contains(&resp.status()) => {
1846 drain_presigned_response(resp);
1847 Ok(())
1848 }
1849 Ok(resp) => Err(presigned_upload_refusal(resp)),
1850 Err(error) => match error {
1851 ureq::Error::Status(412, _) => Ok(()),
1856 ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
1857 ureq::Error::Transport(err) => Err(LinkError::Transport {
1858 hub: "the object store".to_string(),
1859 message: err.to_string(),
1860 }),
1861 },
1862 }
1863}
1864
1865fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
1874 let mut buf = Vec::new();
1875 response
1876 .into_reader()
1877 .take(limit)
1878 .read_to_end(&mut buf)
1879 .map_err(|error| LinkError::Transport {
1880 hub: peer.to_string(),
1881 message: error.to_string(),
1882 })?;
1883 Ok(buf)
1884}
1885
1886fn drain_presigned_response(response: ureq::Response) {
1891 let mut reader = response.into_reader().take(64 * 1024);
1892 let _ = std::io::copy(&mut reader, &mut std::io::sink());
1893}
1894
1895fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
1898 let status = response.status();
1899 let detail = response
1900 .into_string()
1901 .ok()
1902 .map(|body| body.chars().take(400).collect::<String>())
1903 .filter(|body| !body.trim().is_empty());
1904 LinkError::Http {
1905 what: "pack upload",
1906 status,
1907 message: match detail {
1908 Some(body) => format!(
1909 "object store rejected the upload: {}",
1910 body.replace('\n', " ")
1911 ),
1912 None => "object store rejected the upload".to_string(),
1913 },
1914 code: None,
1915 details: None,
1916 }
1917}
1918
1919fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1920 max_bytes.checked_add(1)
1921}
1922
1923fn presigned_download_read_limit() -> u64 {
1924 one_past_bounded_limit(MAX_PACK_BYTES)
1925 .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1926}
1927
1928fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1929 let http = presigned_agent(cfg, raw)?;
1930 let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1931 Ok(resp) => resp,
1932 Err(error) => match *error {
1933 ureq::Error::Status(_, resp) => {
1934 return Err(LinkError::Http {
1935 what: "pack download",
1936 status: resp.status(),
1937 message: "object store rejected the download".to_string(),
1938 code: None,
1939 details: None,
1940 });
1941 }
1942 ureq::Error::Transport(err) => {
1943 return Err(LinkError::Transport {
1944 hub: "the object store".to_string(),
1945 message: err.to_string(),
1946 });
1947 }
1948 },
1949 };
1950 if !(200..300).contains(&resp.status()) {
1951 return Err(LinkError::Http {
1952 what: "pack download",
1953 status: resp.status(),
1954 message: "object store rejected the download".to_string(),
1955 code: None,
1956 details: None,
1957 });
1958 }
1959 let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
1960 if bytes.len() as u64 > MAX_PACK_BYTES {
1961 return Err(LinkError::InvalidPack {
1962 message: "download exceeds the compressed-size limit".to_string(),
1963 });
1964 }
1965 Ok(bytes)
1966}
1967
1968fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
1972 if !(200..300).contains(&r.status) {
1973 let message = r
1974 .body
1975 .as_ref()
1976 .and_then(|b| b.get("error"))
1977 .and_then(Value::as_str)
1978 .unwrap_or("unknown error")
1979 .to_string();
1980 let code = r
1981 .body
1982 .as_ref()
1983 .and_then(|b| b.get("code"))
1984 .and_then(Value::as_str)
1985 .map(str::to_string);
1986 let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
1987 return Err(LinkError::Http {
1988 what,
1989 status: r.status,
1990 message,
1991 code,
1992 details,
1993 });
1994 }
1995 r.body.ok_or(LinkError::NotJson {
1996 what,
1997 status: r.status,
1998 })
1999}
2000
2001fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2010 match ip {
2011 std::net::IpAddr::V4(ip) => {
2012 let [a, b, c, _] = ip.octets();
2013 !(a == 0
2014 || a == 10
2015 || a == 127
2016 || (a == 100 && (64..=127).contains(&b))
2017 || (a == 169 && b == 254)
2018 || (a == 172 && (16..=31).contains(&b))
2019 || (a == 192 && b == 0 && c == 0)
2020 || (a == 192 && b == 0 && c == 2)
2021 || (a == 192 && b == 88 && c == 99)
2022 || (a == 192 && b == 168)
2023 || (a == 198 && (b == 18 || b == 19))
2024 || (a == 198 && b == 51 && c == 100)
2025 || (a == 203 && b == 0 && c == 113)
2026 || a >= 224)
2027 }
2028 std::net::IpAddr::V6(ip) => {
2029 let segments = ip.segments();
2030 (segments[0] & 0xe000) == 0x2000
2035 && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2036 && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2037 && segments[0] != 0x2002
2038 && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2039 }
2040 }
2041}
2042
2043#[derive(Clone)]
2044struct PinnedRegistryResolver {
2045 netloc: String,
2046 addresses: Vec<std::net::SocketAddr>,
2047}
2048
2049impl ureq::Resolver for PinnedRegistryResolver {
2050 fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2051 if requested == self.netloc {
2052 Ok(self.addresses.clone())
2053 } else {
2054 Err(std::io::Error::new(
2055 std::io::ErrorKind::PermissionDenied,
2056 "registry request attempted to resolve an unvalidated authority",
2057 ))
2058 }
2059 }
2060}
2061
2062fn pinned_public_agent(
2063 url: &url::Url,
2064 allow_private: bool,
2065 label: &str,
2066) -> LinkResult<ureq::Agent> {
2067 pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2068}
2069
2070struct AgentShape {
2075 idle_per_host: usize,
2076 overall: std::time::Duration,
2077}
2078
2079impl Default for AgentShape {
2080 fn default() -> Self {
2081 Self {
2082 idle_per_host: 1,
2083 overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2084 }
2085 }
2086}
2087
2088fn pinned_public_agent_pooled(
2089 url: &url::Url,
2090 allow_private: bool,
2091 label: &str,
2092 shape: AgentShape,
2093) -> LinkResult<ureq::Agent> {
2094 let host = url
2095 .host_str()
2096 .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2097 let port = url
2098 .port_or_known_default()
2099 .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2100 let addresses = resolve_addresses_with_deadline(
2101 host,
2102 port,
2103 std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2104 )
2105 .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2106 if addresses.is_empty() {
2107 return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2108 }
2109 if !allow_private
2110 && addresses
2111 .iter()
2112 .any(|address| !is_public_registry_ip(address.ip()))
2113 {
2114 return Err(invalid_feed(format!(
2115 "{label} resolves to a non-public address"
2116 )));
2117 }
2118 let netloc = if host.contains(':') {
2119 format!("[{host}]:{port}")
2120 } else {
2121 format!("{host}:{port}")
2122 };
2123 Ok(agent_builder_with_timeout(shape.overall)
2124 .max_idle_connections_per_host(shape.idle_per_host.max(1))
2125 .resolver(PinnedRegistryResolver { netloc, addresses })
2126 .build())
2127}
2128
2129fn resolve_addresses_with_deadline(
2134 host: &str,
2135 port: u16,
2136 timeout: std::time::Duration,
2137) -> std::io::Result<Vec<std::net::SocketAddr>> {
2138 use std::net::ToSocketAddrs as _;
2139
2140 let host = host.to_string();
2141 let (send, receive) = std::sync::mpsc::sync_channel(1);
2142 std::thread::Builder::new()
2143 .name("dbmd-dns".to_string())
2144 .spawn(move || {
2145 let result = (host.as_str(), port)
2146 .to_socket_addrs()
2147 .map(|addresses| addresses.collect());
2148 let _ = send.send(result);
2149 })
2150 .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2151 match receive.recv_timeout(timeout) {
2152 Ok(result) => result,
2153 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2154 std::io::ErrorKind::TimedOut,
2155 "resolution exceeded its deadline",
2156 )),
2157 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2158 "resolver stopped without returning a result",
2159 )),
2160 }
2161}
2162
2163fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2164 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2165 pinned_public_agent(url, allow_private, "registry home")
2166}
2167
2168fn get_json_absolute(url: &str) -> LinkResult<Value> {
2173 let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2174 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2175 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2176 || !parsed.username().is_empty()
2177 || parsed.password().is_some()
2178 || parsed.query().is_some()
2179 || parsed.fragment().is_some()
2180 {
2181 return Err(invalid_feed("unsafe registry home URL"));
2182 }
2183 let http = registry_agent(&parsed)?;
2184 let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2185 Ok(resp) => resp,
2186 Err(error) => match *error {
2187 ureq::Error::Status(status, resp) => {
2188 let _ = resp;
2189 return Err(LinkError::Http {
2190 what: "registry home fetch",
2191 status,
2192 message: "the home node rejected the card request".to_string(),
2193 code: None,
2194 details: None,
2195 });
2196 }
2197 ureq::Error::Transport(err) => {
2198 return Err(LinkError::Transport {
2199 hub: url.to_string(),
2200 message: err.to_string(),
2201 });
2202 }
2203 },
2204 };
2205 if !(200..300).contains(&resp.status()) {
2206 return Err(LinkError::Http {
2207 what: "registry home fetch",
2208 status: resp.status(),
2209 message: "the home node returned a redirect or error".to_string(),
2210 code: None,
2211 details: None,
2212 });
2213 }
2214 let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2215 if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2216 return Err(LinkError::ResponseTooLarge {
2217 limit_bytes: MAX_REGISTRY_CARD_BYTES,
2218 });
2219 }
2220 serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2221 message: "the home node returned invalid JSON".to_string(),
2222 })
2223}
2224
2225pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2232 require_safe_ref(handle)?;
2233 let trust_directory = open_trust_dir(cfg)?;
2237 let reg = request_capped(
2238 cfg,
2239 "GET",
2240 &format!("/api/hub/registry/{handle}"),
2241 None,
2242 Auth::None,
2243 MAX_REGISTRY_CARD_BYTES,
2244 )?;
2245 if reg.status == 404 {
2246 return Ok(None);
2247 }
2248 let body = ensure_ok(reg, "registry resolve")?;
2249 let home = body
2250 .get("home")
2251 .and_then(Value::as_str)
2252 .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2253 let brain = body
2254 .get("brain")
2255 .and_then(Value::as_str)
2256 .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2257 if !crate::ulid::is_ulid(brain) {
2258 return Err(invalid_feed(
2259 "registry entry brain is not a canonical lowercase ULID",
2260 ));
2261 }
2262 let want_fp = body
2263 .get("identity")
2264 .and_then(|i| i.get("fingerprint"))
2265 .and_then(Value::as_str)
2266 .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2267
2268 let home = home.trim_end_matches('/');
2269 let origin = normalized_origin(home)?;
2270 if origin != home {
2271 return Err(invalid_feed(
2272 "registry home must be an origin without a path, query, or fragment",
2273 ));
2274 }
2275 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2276 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2277 if let Some(binding) = &alias_binding {
2278 if binding
2279 .home
2280 .as_deref()
2281 .is_some_and(|pinned_home| pinned_home != home)
2282 {
2283 return Err(invalid_feed(
2284 "registry relocated a pinned handle to a different home",
2285 ));
2286 }
2287 }
2288 let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2289 if card.get("id").and_then(Value::as_str) != Some(brain) {
2290 return Err(invalid_feed(
2291 "the home node served a card for a different brain",
2292 ));
2293 }
2294 let identity: FeedIdentity = serde_json::from_value(
2295 card.get("identity")
2296 .cloned()
2297 .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2298 )
2299 .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2300 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2301 let got_fp = card
2302 .get("identity")
2303 .and_then(|i| i.get("fingerprint"))
2304 .and_then(Value::as_str)
2305 .unwrap_or_default();
2306 if got_fp != want_fp {
2307 return Err(invalid_feed(
2308 "the home node served an identity that does not match the registry — refusing",
2309 ));
2310 }
2311 let current = format!("ed25519:{}", identity.fingerprint);
2312 let advertised_seq = card
2313 .get("headSeq")
2314 .and_then(Value::as_u64)
2315 .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2316 let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2317 if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2318 || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2319 {
2320 return Err(invalid_feed(
2321 "the home node served an invalid feed head boundary",
2322 ));
2323 }
2324 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2328 let registry_alias = AliasBinding {
2329 v: 1,
2330 origin: normalized_origin(&cfg.hub)?,
2331 requested: handle.to_string(),
2332 brain: brain.to_string(),
2333 home: Some(home.to_string()),
2334 };
2335 save_canonical_pin_and_alias(
2336 cfg,
2337 &trust_directory,
2338 handle,
2339 brain,
2340 TrustState {
2341 v: 2,
2342 origin: normalized_origin(&cfg.hub)?,
2343 requested: brain.to_string(),
2344 brain: brain.to_string(),
2345 home: None,
2346 anchor,
2347 current,
2348 head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2349 feed_hash: pinned
2350 .as_ref()
2351 .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2352 rotations: identity.rotations.clone(),
2353 hub_signer: None,
2354 protocol_profile: None,
2355 },
2356 Some(®istry_alias),
2357 )?;
2358 let mut out = card;
2359 if let Value::Object(map) = &mut out {
2360 map.insert("home".to_string(), Value::String(home.to_string()));
2361 map.insert(
2362 "resolvedVia".to_string(),
2363 Value::String("registry".to_string()),
2364 );
2365 }
2366 Ok(Some(out))
2367}
2368
2369pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2370 require_safe_ref(&addr.brain)?;
2374 if let Some(target) = &addr.target {
2375 let (given, ok) = match target {
2376 AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2377 AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2378 };
2379 if !ok {
2380 return Err(LinkError::BadAddress {
2381 given: given.clone(),
2382 reason: BAD_TARGET_REASON.to_string(),
2383 });
2384 }
2385 }
2386
2387 if let Some(target) = &addr.target {
2393 if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2394 let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2395 what: "resolve",
2396 status: 404,
2397 message: "record not found".to_string(),
2398 code: Some("NOT_FOUND".to_string()),
2399 details: None,
2400 })?;
2401 let (path, file) = match target {
2402 AddressTarget::Path(path) => {
2403 let file =
2404 v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2405 LinkError::Http {
2406 what: "resolve",
2407 status: 404,
2408 message: "record not found".to_string(),
2409 code: Some("NOT_FOUND".to_string()),
2410 details: None,
2411 }
2412 })?;
2413 (path.clone(), file)
2414 }
2415 AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2416 };
2417 let mut downloaded =
2418 download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2419 let (_, bytes) = downloaded
2420 .pop()
2421 .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2422 let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2423 accept_v2_head(cfg, &head)?;
2424 return Ok(resolved);
2425 }
2426 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2427 if !remote.head.verified {
2428 return Err(invalid_feed(
2429 "a path-scoped feed cannot prove a record against the full signed snapshot",
2430 ));
2431 }
2432 if remote.head.seq == 0 {
2433 return Err(LinkError::Http {
2434 what: "resolve",
2435 status: 404,
2436 message: "record not found".to_string(),
2437 code: Some("NOT_FOUND".to_string()),
2438 details: None,
2439 });
2440 }
2441 let brain = remote.head.brain.clone();
2442 let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2443 return resolve_from_verified_pack(&brain, target, pack);
2444 }
2445
2446 let path = format!("/api/hub/brains/{}", addr.brain);
2447 let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2452 if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2453 if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2454 return Ok(card);
2455 }
2456 }
2457 let mut resolved = ensure_ok(direct, "resolve")?;
2458 if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2459 let v2 = v2_verified_head(cfg, &addr.brain)?
2460 .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2461 if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2462 return Err(invalid_feed(
2463 "resolve card is not bound to the verified v2 brain",
2464 ));
2465 }
2466 let card_identity: FeedIdentity = serde_json::from_value(
2467 resolved
2468 .get("identity")
2469 .cloned()
2470 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2471 )
2472 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2473 if card_identity != v2_identity(&v2.identity) {
2474 return Err(invalid_feed(
2475 "resolve card identity differs from the verified v2 identity",
2476 ));
2477 }
2478 accept_v2_head(cfg, &v2)?;
2479 if let Value::Object(card) = &mut resolved {
2480 card.insert(
2481 "headSeq".to_string(),
2482 json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2483 );
2484 card.insert(
2485 "feedHash".to_string(),
2486 v2.pointer
2487 .as_ref()
2488 .map(|pointer| Value::String(pointer.feed_hash.clone()))
2489 .unwrap_or(Value::Null),
2490 );
2491 card.insert(
2492 "storageProfile".to_string(),
2493 Value::String("v2".to_string()),
2494 );
2495 if let Some(pointer) = &v2.pointer {
2496 card.insert(
2497 "updatedAt".to_string(),
2498 Value::String(pointer.signed_at.clone()),
2499 );
2500 }
2501 }
2502 return Ok(resolved);
2503 }
2504 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2508 if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2509 || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2510 || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2511 {
2512 return Err(invalid_feed(
2513 "resolve card is not bound to the exact verified feed checkpoint",
2514 ));
2515 }
2516 let card_identity: FeedIdentity = serde_json::from_value(
2517 resolved
2518 .get("identity")
2519 .cloned()
2520 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2521 )
2522 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2523 if remote.identity.as_ref() != Some(&card_identity) {
2524 return Err(invalid_feed(
2525 "resolve card identity differs from the verified feed identity",
2526 ));
2527 }
2528 Ok(resolved)
2529}
2530
2531fn resolve_from_verified_pack(
2536 brain: &str,
2537 target: &AddressTarget,
2538 pack: Vec<u8>,
2539) -> LinkResult<Value> {
2540 let entries = parse_store_pack(pack)?;
2541 let mut matched: Option<(String, Vec<u8>)> = None;
2542
2543 for (path, bytes) in entries {
2544 let is_candidate = match target {
2545 AddressTarget::Path(want) => &path == want,
2546 AddressTarget::Id(_) => {
2547 path.ends_with(".md")
2548 && (path.starts_with("records/") || path.starts_with("sources/"))
2549 }
2550 };
2551 if !is_candidate {
2552 continue;
2553 }
2554 let text = std::str::from_utf8(&bytes)
2555 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2556 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2557 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2558 if let AddressTarget::Id(want) = target {
2559 let frontmatter =
2560 crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2561 .map_err(|_| {
2562 invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2563 })?;
2564 if frontmatter.id.as_deref() != Some(want) {
2565 continue;
2566 }
2567 }
2568 if matched.is_some() {
2569 return Err(invalid_feed(
2570 "signed snapshot contains more than one record for the requested target",
2571 ));
2572 }
2573 matched = Some((path, bytes));
2574 }
2575
2576 let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2577 what: "resolve",
2578 status: 404,
2579 message: "record not found".to_string(),
2580 code: Some("NOT_FOUND".to_string()),
2581 details: None,
2582 })?;
2583 resolve_from_verified_record_bytes(brain, target, path, bytes)
2584}
2585
2586fn resolve_from_verified_record_bytes(
2587 brain: &str,
2588 target: &AddressTarget,
2589 path: String,
2590 bytes: Vec<u8>,
2591) -> LinkResult<Value> {
2592 match target {
2593 AddressTarget::Path(expected) if expected != &path => {
2594 return Err(invalid_feed(
2595 "verified record path differs from the requested path",
2596 ));
2597 }
2598 AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2599 return Err(invalid_feed(
2600 "verified id resolved outside records or sources",
2601 ));
2602 }
2603 _ => {}
2604 }
2605 let text = std::str::from_utf8(&bytes)
2606 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2607 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2608 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2609 let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2610 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2611 let Value::Object(fields) = frontmatter else {
2612 return Err(invalid_feed(format!(
2613 "signed snapshot record `{path}` frontmatter is not a mapping"
2614 )));
2615 };
2616 if let AddressTarget::Id(expected) = target {
2617 if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2618 return Err(invalid_feed(
2619 "verified record id differs from the requested id",
2620 ));
2621 }
2622 }
2623 let mut document = serde_json::Map::new();
2624 document.insert("path".to_string(), Value::String(path));
2625 for (key, value) in fields {
2626 document.insert(key, value);
2627 }
2628 document.insert("body".to_string(), Value::String(parsed.body));
2629 document.insert(
2630 "contentSha".to_string(),
2631 Value::String(content_sha256(&bytes)),
2632 );
2633 Ok(json!({
2634 "brain": brain,
2635 "document": Value::Object(document),
2636 }))
2637}
2638
2639#[derive(Debug, Clone, serde::Serialize)]
2645pub struct PullReport {
2646 pub brain: String,
2648 pub slug: String,
2650 #[serde(rename = "headSeq")]
2652 pub head_seq: u64,
2653 pub files: usize,
2655 pub dest: String,
2657 #[serde(rename = "extraLocal")]
2660 pub extra_local: Vec<String>,
2661 #[serde(rename = "syncStatus")]
2663 pub sync_status: String,
2664}
2665
2666struct V2PulledSnapshot {
2667 report: PullReport,
2668 head: V2VerifiedHead,
2669 files: std::collections::BTreeMap<String, V2BaselineFile>,
2670 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2671 local: V2LocalView,
2672 local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2673}
2674
2675fn download_verified_snapshot_pack(
2676 cfg: &HubConfig,
2677 brain: &str,
2678 remote: &VerifiedRemote,
2679) -> LinkResult<Vec<u8>> {
2680 let feed_hash = remote
2681 .head
2682 .feed_hash
2683 .as_deref()
2684 .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2685 let signed_head = remote
2686 .head_entry
2687 .as_ref()
2688 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2689 let expected = &signed_head.entry.pack_sha256;
2690 if !is_sha256(expected) {
2691 return Err(invalid_feed(
2692 "signed head carries an invalid snapshot pack digest",
2693 ));
2694 }
2695 let path = format!(
2696 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2697 remote.head.seq
2698 );
2699 let body = ensure_ok(
2700 request(cfg, "GET", &path, None, Auth::Required)?,
2701 "sync pull",
2702 )?;
2703 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2704 || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2705 || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2706 || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2707 {
2708 return Err(invalid_feed(
2709 "export response is not bound to the exact verified snapshot",
2710 ));
2711 }
2712 let url = body
2713 .get("url")
2714 .and_then(Value::as_str)
2715 .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2716 let bytes = get_presigned(cfg, url)?;
2717 if content_sha256(&bytes) != *expected {
2718 return Err(LinkError::InvalidPack {
2719 message: "downloaded pack does not match the signed snapshot digest".to_string(),
2720 });
2721 }
2722 let entries = parse_store_pack(bytes.clone())?;
2723 if signed_head.entry.kind == "push" {
2724 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2725 }
2726 Ok(bytes)
2727}
2728
2729#[derive(Debug, Clone, Deserialize, Serialize)]
2730struct V2PointerBody {
2731 v: u8,
2732 brain: String,
2733 seq: u64,
2734 commit_hash: String,
2735 feed_hash: String,
2736 content_root: Option<String>,
2737 asset_root: Option<String>,
2738 materializer: String,
2739 signer_epoch: u64,
2740 control_revision: String,
2741 backup_preparation: String,
2742 prior_pointer_hash: Option<String>,
2743 signed_at: String,
2744}
2745
2746#[derive(Debug, Clone, Deserialize)]
2747struct V2SignedPointer {
2748 pointer: V2PointerBody,
2749 hub_public_key: String,
2750 hub_fingerprint: String,
2751 sig: String,
2752}
2753
2754#[derive(Debug, Clone, Deserialize)]
2755struct V2HeadIdentity {
2756 #[serde(default)]
2757 custody: String,
2758 fingerprint: String,
2759 public_key_spki: String,
2760 #[serde(default)]
2761 previous: Vec<V2PreviousIdentity>,
2762 #[serde(default)]
2763 rotations: Vec<String>,
2764}
2765
2766#[derive(Debug, Clone, Deserialize)]
2767struct V2PreviousIdentity {
2768 fingerprint: String,
2769 public_key_spki: String,
2770}
2771
2772#[derive(Debug, Deserialize)]
2773struct V2HeadResponse {
2774 v: u8,
2775 brain_id: String,
2776 profile: String,
2777 view: Option<V2HeadView>,
2778 pointer: Option<V2SignedPointer>,
2779 identity: Option<V2HeadIdentity>,
2780}
2781
2782#[derive(Debug, Clone, Deserialize)]
2783struct V2HeadView {
2784 kind: String,
2785 #[serde(default)]
2786 id: Option<String>,
2787 control_revision: String,
2788}
2789
2790#[derive(Debug, Clone)]
2791struct V2VerifiedHead {
2792 requested: String,
2793 brain_id: String,
2794 view_kind: String,
2795 view_revision: String,
2797 control_revision: String,
2799 identity: V2HeadIdentity,
2800 pointer: Option<V2PointerBody>,
2801 trust: TrustState,
2802 alias: Option<AliasBinding>,
2803}
2804
2805fn verify_v2_spki_signature(
2806 public_key: &str,
2807 message: &[u8],
2808 signature: &str,
2809) -> LinkResult<Vec<u8>> {
2810 let der = URL_SAFE_NO_PAD
2811 .decode(public_key)
2812 .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2813 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2814 return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2815 }
2816 let sig = URL_SAFE_NO_PAD
2817 .decode(signature)
2818 .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2819 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2820 .verify(message, &sig)
2821 .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2822 Ok(der)
2823}
2824
2825fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2826 if pointer.pointer.v != 2
2827 || pointer.pointer.brain != expected_brain
2828 || pointer.pointer.seq == 0
2829 || !is_sha256(&pointer.pointer.commit_hash)
2830 || !is_sha256(&pointer.pointer.feed_hash)
2831 || pointer
2832 .pointer
2833 .content_root
2834 .as_deref()
2835 .is_some_and(|hash| !is_sha256(hash))
2836 || !is_sha256(&pointer.pointer.backup_preparation)
2837 {
2838 return Err(invalid_feed("v2 pointer fields are invalid"));
2839 }
2840 let value = serde_json::to_value(&pointer.pointer)
2841 .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2842 let message = crate::linkmd_v2::canonical_bytes(&value)
2843 .map_err(|error| invalid_feed(error.to_string()))?;
2844 let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2845 let fingerprint = format!("{:x}", Sha256::digest(&der));
2846 if fingerprint != pointer.hub_fingerprint {
2847 return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2848 }
2849 Ok(format!(
2850 "{}:{}",
2851 pointer.hub_fingerprint, pointer.hub_public_key
2852 ))
2853}
2854
2855fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2856 FeedIdentity {
2857 fingerprint: identity.fingerprint.clone(),
2858 public_key_spki: identity.public_key_spki.clone(),
2859 previous: identity
2860 .previous
2861 .iter()
2862 .map(|previous| PreviousIdentity {
2863 fingerprint: previous.fingerprint.clone(),
2864 public_key_spki: previous.public_key_spki.clone(),
2865 })
2866 .collect(),
2867 rotations: identity.rotations.clone(),
2868 }
2869}
2870
2871fn verified_v2_commit_object(
2872 raw: &[u8],
2873 identity: &V2HeadIdentity,
2874) -> LinkResult<serde_json::Map<String, Value>> {
2875 let mut value: Value =
2876 serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2877 let canonical = crate::linkmd_v2::canonical_bytes(&value)
2878 .map_err(|error| invalid_feed(error.to_string()))?;
2879 if canonical != raw {
2880 return Err(invalid_feed("v2 commit is not canonical JSON"));
2881 }
2882 let object = value
2883 .as_object_mut()
2884 .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
2885 let sig = object
2886 .remove("sig")
2887 .and_then(|value| value.as_str().map(str::to_string))
2888 .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
2889 const FIELDS: [&str; 18] = [
2890 "actor_ref",
2891 "asset_root",
2892 "brain",
2893 "changes_sha256",
2894 "control_revision",
2895 "materializer",
2896 "op",
2897 "parent_asset_root",
2898 "parent_commit",
2899 "parent_root",
2900 "prev_entry_hash",
2901 "public_key",
2902 "seq",
2903 "signer_epoch",
2904 "state_root",
2905 "ts",
2906 "v",
2907 "v1_bridge",
2908 ];
2909 if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
2910 return Err(invalid_feed("v2 commit has a non-normative field set"));
2911 }
2912 let seq = object
2913 .get("seq")
2914 .and_then(Value::as_u64)
2915 .filter(|seq| *seq > 0)
2916 .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
2917 let signer_epoch = object
2918 .get("signer_epoch")
2919 .and_then(Value::as_u64)
2920 .filter(|epoch| *epoch > 0)
2921 .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
2922 let hash_or_null = |field: &str| {
2923 object
2924 .get(field)
2925 .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
2926 };
2927 if object.get("v").and_then(Value::as_u64) != Some(2)
2928 || object.get("op").and_then(Value::as_str) != Some("changeset")
2929 || !object
2930 .get("changes_sha256")
2931 .and_then(Value::as_str)
2932 .is_some_and(is_sha256)
2933 || !object
2934 .get("actor_ref")
2935 .and_then(Value::as_str)
2936 .is_some_and(is_sha256)
2937 || !object
2938 .get("control_revision")
2939 .and_then(Value::as_str)
2940 .is_some_and(is_sha256)
2941 || !object
2942 .get("state_root")
2943 .and_then(Value::as_str)
2944 .is_some_and(is_sha256)
2945 || !hash_or_null("parent_commit")
2946 || !hash_or_null("parent_root")
2947 || !hash_or_null("parent_asset_root")
2948 || !hash_or_null("asset_root")
2949 || !hash_or_null("prev_entry_hash")
2950 || !object
2951 .get("materializer")
2952 .and_then(Value::as_str)
2953 .is_some_and(|value| !value.is_empty() && value.len() <= 128)
2954 || !object
2955 .get("ts")
2956 .and_then(Value::as_str)
2957 .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
2958 {
2959 return Err(invalid_feed("v2 commit fields are invalid"));
2960 }
2961 if (seq == 1
2962 && [
2963 "parent_commit",
2964 "parent_root",
2965 "parent_asset_root",
2966 "prev_entry_hash",
2967 ]
2968 .iter()
2969 .any(|field| !object.get(*field).is_some_and(Value::is_null)))
2970 || (seq > 1
2971 && ["parent_commit", "parent_root", "prev_entry_hash"]
2972 .iter()
2973 .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
2974 {
2975 return Err(invalid_feed("v2 commit parent shape is invalid"));
2976 }
2977 match object.get("v1_bridge") {
2978 Some(Value::Null) => {}
2979 Some(Value::Object(bridge))
2980 if seq == 1
2981 && bridge.len() == 3
2982 && bridge
2983 .get("head_seq")
2984 .and_then(Value::as_u64)
2985 .is_some_and(|v| v > 0)
2986 && bridge
2987 .get("feed_hash")
2988 .and_then(Value::as_str)
2989 .is_some_and(is_sha256)
2990 && bridge
2991 .get("pack_sha256")
2992 .and_then(Value::as_str)
2993 .is_some_and(is_sha256) => {}
2994 _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
2995 }
2996 let public_key = object
2997 .get("public_key")
2998 .and_then(Value::as_str)
2999 .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3000 let der = URL_SAFE_NO_PAD
3001 .decode(public_key)
3002 .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3003 let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3004 if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3005 return Err(invalid_feed("v2 commit brain identity mismatch"));
3006 }
3007 verify_identity_chain(&v2_identity(identity), None)?;
3009 let mut chain: Vec<(&str, &str)> = identity
3012 .previous
3013 .iter()
3014 .rev()
3015 .map(|previous| {
3016 (
3017 previous.fingerprint.as_str(),
3018 previous.public_key_spki.as_str(),
3019 )
3020 })
3021 .collect();
3022 chain.push((&identity.fingerprint, &identity.public_key_spki));
3023 let signer_index = chain.iter().position(|(fingerprint, spki)| {
3024 *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3025 });
3026 let Some(signer_index) = signer_index else {
3027 return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3028 };
3029 if signer_epoch != signer_index as u64 + 1 {
3030 return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3031 }
3032 let lower_boundary = if signer_index == 0 {
3033 None
3034 } else {
3035 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3036 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3037 Some(prior.prior_head_seq)
3038 };
3039 let upper_boundary = if signer_index == identity.rotations.len() {
3040 None
3041 } else {
3042 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3043 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3044 Some(next.prior_head_seq)
3045 };
3046 if lower_boundary.is_some_and(|boundary| seq <= boundary)
3047 || upper_boundary.is_some_and(|boundary| seq > boundary)
3048 {
3049 return Err(invalid_feed(
3050 "v2 commit signer is outside its authenticated rotation epoch",
3051 ));
3052 }
3053 let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3054 .map_err(|error| invalid_feed(error.to_string()))?;
3055 verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3056 Ok(object.clone())
3057}
3058
3059#[derive(Debug, Deserialize)]
3060struct V2FeedWireEntry {
3061 seq: u64,
3062 commit_hash: String,
3063 feed_hash: String,
3064 bytes_base64: String,
3065}
3066
3067#[derive(Debug, Deserialize)]
3068struct V2FeedPage {
3069 v: u8,
3070 head_seq: u64,
3071 head_commit_hash: String,
3072 head_feed_hash: String,
3073 entries: Vec<V2FeedWireEntry>,
3074 next_after: u64,
3075 complete: bool,
3076}
3077
3078fn replay_v2_feed(
3079 cfg: &HubConfig,
3080 brain: &str,
3081 pointer: &V2PointerBody,
3082 identity: &V2HeadIdentity,
3083 start_after: u64,
3084 start_feed: Option<String>,
3085) -> LinkResult<()> {
3086 let mut after = start_after;
3087 let mut prior_feed = start_feed;
3088 let mut final_object = None;
3089 let mut replayed_entries = 0_u64;
3090 let mut replayed_bytes = 0_u64;
3091 while after < pointer.seq {
3092 let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3093 let value = ensure_ok(
3094 request_capped(
3095 cfg,
3096 "GET",
3097 &path,
3098 None,
3099 Auth::Required,
3100 MAX_FEED_REPLAY_BYTES,
3101 )?,
3102 "v2 feed replay",
3103 )?;
3104 let page: V2FeedPage = serde_json::from_value(value)
3105 .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3106 if page.v != 2
3107 || page.head_seq != pointer.seq
3108 || page.head_commit_hash != pointer.commit_hash
3109 || page.head_feed_hash != pointer.feed_hash
3110 || page.entries.is_empty()
3111 || page.entries.len() > FEED_PAGE_LIMIT
3112 {
3113 return Err(invalid_feed("v2 feed page differs from the signed head"));
3114 }
3115 for entry in page.entries {
3116 if entry.seq != after + 1
3117 || !is_sha256(&entry.commit_hash)
3118 || !is_sha256(&entry.feed_hash)
3119 {
3120 return Err(invalid_feed("v2 feed sequence is not contiguous"));
3121 }
3122 let raw = base64::engine::general_purpose::STANDARD
3123 .decode(&entry.bytes_base64)
3124 .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3125 replayed_entries = replayed_entries
3126 .checked_add(1)
3127 .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3128 replayed_bytes = replayed_bytes
3129 .checked_add(raw.len() as u64)
3130 .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3131 if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3132 {
3133 return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3134 }
3135 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3136 .map_err(|error| invalid_feed(error.to_string()))?
3137 != entry.commit_hash
3138 || content_sha256(&raw) != entry.feed_hash
3139 {
3140 return Err(invalid_feed("v2 feed entry address mismatch"));
3141 }
3142 let object = verified_v2_commit_object(&raw, identity)?;
3143 if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3144 || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3145 {
3146 return Err(invalid_feed(
3147 "v2 feed entry does not extend its predecessor",
3148 ));
3149 }
3150 after = entry.seq;
3151 prior_feed = Some(entry.feed_hash);
3152 final_object = Some((entry.commit_hash, object));
3153 }
3154 if page.next_after != after || (page.complete != (after == pointer.seq)) {
3155 return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3156 }
3157 }
3158 let (final_hash, object) =
3159 final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3160 if final_hash != pointer.commit_hash
3161 || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3162 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3163 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3164 || object.get("control_revision").and_then(Value::as_str)
3165 != Some(pointer.control_revision.as_str())
3166 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3167 {
3168 return Err(invalid_feed(
3169 "v2 replay did not converge on the signed pointer",
3170 ));
3171 }
3172 Ok(())
3173}
3174
3175fn verify_v1_to_v2_bridge(
3176 cfg: &HubConfig,
3177 brain: &str,
3178 pointer: &V2PointerBody,
3179 identity: &V2HeadIdentity,
3180 checkpoint: &TrustState,
3181) -> LinkResult<()> {
3182 let value = ensure_ok(
3183 request_capped(
3184 cfg,
3185 "GET",
3186 &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3187 None,
3188 Auth::Required,
3189 MAX_FEED_RESPONSE_BYTES,
3190 )?,
3191 "v2 genesis bridge",
3192 )?;
3193 let page: V2FeedPage = serde_json::from_value(value)
3194 .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3195 if page.v != 2
3196 || page.head_seq != pointer.seq
3197 || page.head_commit_hash != pointer.commit_hash
3198 || page.head_feed_hash != pointer.feed_hash
3199 || page.entries.len() != 1
3200 || page.entries[0].seq != 1
3201 || !is_sha256(&page.entries[0].commit_hash)
3202 || !is_sha256(&page.entries[0].feed_hash)
3203 {
3204 return Err(invalid_feed(
3205 "v2 genesis bridge page differs from the signed head",
3206 ));
3207 }
3208 let first = &page.entries[0];
3209 let raw = STANDARD
3210 .decode(&first.bytes_base64)
3211 .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3212 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3213 .map_err(|error| invalid_feed(error.to_string()))?
3214 != first.commit_hash
3215 || content_sha256(&raw) != first.feed_hash
3216 {
3217 return Err(invalid_feed("v2 genesis bridge address mismatch"));
3218 }
3219 let object = verified_v2_commit_object(&raw, identity)?;
3220 if checkpoint.head_seq == 0 {
3221 if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3222 return Err(invalid_feed(
3223 "empty v1 checkpoint did not transition through an empty v2 genesis",
3224 ));
3225 }
3226 return Ok(());
3227 }
3228 let bridge = object
3229 .get("v1_bridge")
3230 .and_then(Value::as_object)
3231 .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3232 let checkpoint_feed = checkpoint
3233 .feed_hash
3234 .as_deref()
3235 .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3236 if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3237 || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3238 {
3239 return Err(invalid_feed(
3240 "v2 genesis bridge differs from the pinned v1 checkpoint",
3241 ));
3242 }
3243 let legacy_raw = ensure_raw_ok(
3244 request_raw(
3245 cfg,
3246 "GET",
3247 &format!(
3248 "/api/hub/brains/{brain}/feed?after={}&limit=1",
3249 checkpoint.head_seq - 1
3250 ),
3251 None,
3252 Auth::Required,
3253 MAX_FEED_RESPONSE_BYTES,
3254 )?,
3255 "v1 bridge boundary",
3256 )?;
3257 let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3258 .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3259 let legacy_identity = legacy
3260 .identity
3261 .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3262 let item = legacy
3263 .entries
3264 .first()
3265 .filter(|_| legacy.entries.len() == 1)
3266 .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3267 if legacy.scope_limited
3268 || legacy.head_seq != checkpoint.head_seq
3269 || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3270 || item.entry.seq != checkpoint.head_seq
3271 || item.hash != checkpoint_feed
3272 || legacy_identity != v2_identity(identity)
3273 || bridge.get("pack_sha256").and_then(Value::as_str)
3274 != Some(item.entry.pack_sha256.as_str())
3275 {
3276 return Err(invalid_feed(
3277 "v1 bridge boundary differs from its signed legacy head",
3278 ));
3279 }
3280 let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3281 if anchor != checkpoint.anchor {
3282 return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3283 }
3284 verify_feed_item(item, &legacy_identity)?;
3285 verify_rotation_feed_boundaries(
3286 &legacy_identity,
3287 Some(checkpoint),
3288 std::slice::from_ref(item),
3289 checkpoint.head_seq,
3290 )?;
3291 Ok(())
3292}
3293
3294fn verify_v2_commit(
3295 cfg: &HubConfig,
3296 brain: &str,
3297 pointer: &V2PointerBody,
3298 identity: &V2HeadIdentity,
3299 pinned: Option<&TrustState>,
3300) -> LinkResult<()> {
3301 let path = format!(
3302 "/api/hub/brains/{brain}/v2/commit?commit={}",
3303 pointer.commit_hash
3304 );
3305 let raw = ensure_raw_ok(
3306 request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3307 "v2 commit",
3308 )?;
3309 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3310 .map_err(|error| invalid_feed(error.to_string()))?
3311 != pointer.commit_hash
3312 || content_sha256(&raw) != pointer.feed_hash
3313 {
3314 return Err(invalid_feed("v2 commit address differs from the pointer"));
3315 }
3316 let object = verified_v2_commit_object(&raw, identity)?;
3317 if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3318 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3319 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3320 || object.get("control_revision").and_then(Value::as_str)
3321 != Some(pointer.control_revision.as_str())
3322 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3323 {
3324 return Err(invalid_feed("v2 commit fields differ from the pointer"));
3325 }
3326 if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3327 if pointer.seq == checkpoint.head_seq + 1
3328 && object.get("prev_entry_hash").and_then(Value::as_str)
3329 != checkpoint.feed_hash.as_deref()
3330 {
3331 return Err(invalid_feed(
3332 "v2 commit does not extend the pinned feed hash",
3333 ));
3334 }
3335 if pointer.seq > checkpoint.head_seq + 1 {
3336 return replay_v2_feed(
3337 cfg,
3338 brain,
3339 pointer,
3340 identity,
3341 checkpoint.head_seq,
3342 checkpoint.feed_hash.clone(),
3343 );
3344 }
3345 } else {
3346 if let Some(checkpoint) = pinned {
3347 verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3348 }
3349 if pointer.seq > 1 {
3350 return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3351 }
3352 }
3353 Ok(())
3354}
3355
3356fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3357 require_hardened_filesystem("verified link.md v2 state")?;
3358 require_safe_ref(brain)?;
3359 let trust_directory = open_trust_dir(cfg)?;
3363 let path = format!("/api/hub/brains/{brain}/v2/head");
3364 let response = request(cfg, "GET", &path, None, Auth::Required)?;
3365 if response.status == 404 {
3366 if has_accepted_v2_ref(cfg, brain)? {
3367 return Err(LinkError::BrainUnavailable);
3368 }
3369 return Ok(None);
3370 }
3371 let body = ensure_ok(response, "v2 head")?;
3372 let head: V2HeadResponse = serde_json::from_value(body)
3373 .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3374 if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3375 return Err(invalid_feed("v2 head has no canonical brain id"));
3376 }
3377 if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3378 return Err(invalid_feed("v2 head resolved a different brain id"));
3379 }
3380 if head.profile == "v1" {
3381 return Ok(None);
3382 }
3383 if head.profile != "v2" && head.profile != "v2-empty" {
3384 return Err(invalid_feed("v2 head advertised an unknown profile"));
3385 }
3386 let view = head
3387 .view
3388 .as_ref()
3389 .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3390 if !matches!(view.kind.as_str(), "full" | "scoped")
3391 || !is_sha256(&view.control_revision)
3392 || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3393 {
3394 return Err(invalid_feed("v2 head has an invalid permission view"));
3395 }
3396 let view_kind = view.kind.clone();
3397 let view_revision = view
3400 .id
3401 .clone()
3402 .unwrap_or_else(|| view.control_revision.clone());
3403 let control_revision = view.control_revision.clone();
3404 let identity = head
3405 .identity
3406 .as_ref()
3407 .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3408 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3409 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3410 let feed_identity = v2_identity(identity);
3411 let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3412 let (seq, feed_hash, hub_signer) = match &head.pointer {
3413 None => {
3414 if head.profile != "v2-empty" {
3415 return Err(invalid_feed("initialized v2 head has no pointer"));
3416 }
3417 (
3418 0,
3419 None,
3420 pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3421 )
3422 }
3423 Some(signed) => {
3424 let signer = verify_v2_pointer(signed, &head.brain_id)?;
3425 if pinned
3426 .as_ref()
3427 .and_then(|state| state.hub_signer.as_ref())
3428 .is_some_and(|known| known != &signer)
3429 {
3430 return Err(invalid_feed(
3431 "v2 hub pointer signer changed without a trust transition",
3432 ));
3433 }
3434 if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3435 if signed.pointer.seq < checkpoint.head_seq
3436 || (signed.pointer.seq == checkpoint.head_seq
3437 && checkpoint.feed_hash.as_deref()
3438 != Some(signed.pointer.feed_hash.as_str()))
3439 {
3440 return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3441 }
3442 }
3443 verify_v2_commit(
3444 cfg,
3445 &head.brain_id,
3446 &signed.pointer,
3447 identity,
3448 pinned.as_ref(),
3449 )?;
3450 (
3451 signed.pointer.seq,
3452 Some(signed.pointer.feed_hash.clone()),
3453 Some(signer),
3454 )
3455 }
3456 };
3457 let trust = TrustState {
3458 v: 2,
3459 origin: normalized_origin(&cfg.hub)?,
3460 requested: head.brain_id.clone(),
3461 brain: head.brain_id.clone(),
3462 home: None,
3463 anchor,
3464 current: format!("ed25519:{}", identity.fingerprint),
3465 head_seq: seq,
3466 feed_hash,
3467 rotations: identity.rotations.clone(),
3468 hub_signer,
3469 protocol_profile: Some("link-v2".to_string()),
3470 };
3471 Ok(Some(V2VerifiedHead {
3472 requested: brain.to_string(),
3473 brain_id: head.brain_id,
3474 view_kind,
3475 view_revision,
3476 control_revision,
3477 identity: identity.clone(),
3478 pointer: head.pointer.map(|signed| signed.pointer),
3479 trust,
3480 alias: alias_binding,
3481 }))
3482}
3483
3484fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3485 let directory = open_trust_dir(cfg)?;
3486 let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3487 let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3488 if let Some(current) = current {
3489 let common_invalid = head.trust.anchor != current.anchor
3490 || !head.trust.rotations.starts_with(¤t.rotations);
3491 let profile_invalid = if accepted_as_v2(¤t) {
3492 head.trust.head_seq < current.head_seq
3493 || (head.trust.head_seq == current.head_seq
3494 && head.trust.feed_hash != current.feed_hash)
3495 || current
3496 .hub_signer
3497 .as_ref()
3498 .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3499 } else {
3500 head.trust.protocol_profile.as_deref() != Some("link-v2")
3501 || head.trust.hub_signer.is_none()
3502 };
3503 if common_invalid || profile_invalid {
3504 return Err(invalid_feed(
3505 "v2 head cannot advance the currently accepted trust checkpoint",
3506 ));
3507 }
3508 }
3509 save_canonical_pin_and_alias(
3510 cfg,
3511 &directory,
3512 &head.requested,
3513 &head.brain_id,
3514 head.trust.clone(),
3515 alias.as_ref().or(head.alias.as_ref()),
3516 )
3517}
3518
3519#[derive(Debug, Clone, Deserialize, Serialize)]
3520struct V2BaselineFile {
3521 sha256: String,
3522 bytes: u64,
3523 #[serde(skip)]
3524 proof: Option<Vec<V2ProofStep>>,
3525}
3526
3527#[derive(Debug, Clone, Deserialize, Serialize)]
3528struct V2SyncBaseline {
3529 v: u8,
3530 origin: String,
3531 brain: String,
3532 #[serde(default)]
3533 checkout_id: Option<String>,
3534 #[serde(default)]
3535 head_seq: Option<u64>,
3536 commit_hash: Option<String>,
3537 content_root: Option<String>,
3538 #[serde(default)]
3539 asset_root: Option<String>,
3540 #[serde(default)]
3541 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3542 #[serde(default)]
3543 view_kind: Option<String>,
3544 #[serde(default)]
3545 view_revision: Option<String>,
3546 #[serde(default)]
3547 projection_sha256: Option<String>,
3548 files: std::collections::BTreeMap<String, V2BaselineFile>,
3549 #[serde(default)]
3550 local_policy_digest: Option<String>,
3551 #[serde(default)]
3552 local_eligibility: std::collections::BTreeMap<String, bool>,
3553 #[serde(default)]
3554 remote_copy_remains: std::collections::BTreeMap<String, String>,
3555}
3556
3557struct V2LocalView {
3558 riding: std::collections::BTreeMap<String, (String, u64)>,
3559 eligibility: std::collections::BTreeMap<String, bool>,
3560 policy: crate::linkmd_sync_policy::SyncPolicy,
3561 withheld_links: Vec<V2WithheldLink>,
3562}
3563
3564#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3565struct V2WithheldLink {
3566 source: String,
3567 target: String,
3568}
3569
3570#[derive(Debug, Clone, Deserialize, Serialize)]
3571struct V2ProofStep {
3572 directory_root: String,
3573 component: String,
3574 proof: crate::linkmd_v2::HamtProof,
3575}
3576
3577#[derive(Debug, Deserialize)]
3578struct V2ManifestFile {
3579 path: String,
3580 sha256: String,
3581 bytes: u64,
3582 proof: Vec<V2ProofStep>,
3583}
3584
3585#[derive(Debug, Deserialize)]
3586struct V2ManifestPage {
3587 v: u8,
3588 commit: String,
3589 content_root: Option<String>,
3590 files: Vec<V2ManifestFile>,
3591 next_cursor: Option<String>,
3592}
3593
3594#[derive(Debug, Clone, Deserialize, Serialize)]
3595struct V2BaselineAsset {
3596 blob_sha256: String,
3597 bytes: u64,
3598 media_type: String,
3599 wrappers: Vec<String>,
3600 required: bool,
3601 disposition: String,
3602 leaf_hash: String,
3603}
3604
3605#[derive(Debug, Deserialize)]
3606struct V2AssetManifestItem {
3607 path: String,
3608 blob_sha256: String,
3609 bytes: u64,
3610 media_type: String,
3611 wrappers: Vec<String>,
3612 required: bool,
3613 disposition: String,
3614 leaf_hash: String,
3615 proof: crate::linkmd_v2::HamtProof,
3616}
3617
3618#[derive(Debug, Deserialize)]
3619struct V2AssetManifestPage {
3620 v: u8,
3621 commit: String,
3622 asset_root: Option<String>,
3623 assets: Vec<V2AssetManifestItem>,
3624 next_cursor: Option<String>,
3625}
3626
3627#[derive(Debug, Deserialize)]
3628struct V2SigningCandidate {
3629 seq: u64,
3630 content_root: Option<String>,
3631 asset_root: Option<String>,
3632 signing_bytes_base64: String,
3633 changes_base64: String,
3634 actor_claim_base64: String,
3635}
3636
3637#[derive(Debug, Deserialize)]
3638struct V2SigningCandidatePage {
3639 v: u8,
3640 challenge_id: String,
3641 mutation_id: String,
3642 request_hash: String,
3643 parent: V2SigningParent,
3644 candidate: V2SigningCandidate,
3645 files: Vec<V2ManifestFile>,
3646 #[serde(default)]
3647 assets: Vec<V2AssetManifestItem>,
3648 next_cursor: Option<String>,
3649 expires_at: String,
3650}
3651
3652#[derive(Debug, Deserialize)]
3653struct V2SigningParent {
3654 seq: u64,
3655 commit_hash: Option<String>,
3656}
3657
3658fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3659 let normalized = crate::linkmd_v2::normalize_path(&file.path)
3660 .map_err(|error| invalid_feed(error.to_string()))?;
3661 let components = normalized.split('/').collect::<Vec<_>>();
3662 if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3663 return Err(invalid_feed("v2 file proof has the wrong shape"));
3664 }
3665 let mut directory_root = root.to_string();
3666 for (index, step) in file.proof.iter().enumerate() {
3667 if step.directory_root != directory_root || step.component != components[index] {
3668 return Err(invalid_feed(
3669 "v2 file proof path chain differs from its manifest",
3670 ));
3671 }
3672 if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3673 .map_err(|error| invalid_feed(error.to_string()))?
3674 {
3675 return Err(invalid_feed("v2 file proof failed verification"));
3676 }
3677 let entry = match &step.proof {
3678 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3679 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3680 return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3681 }
3682 };
3683 if index + 1 == components.len() {
3684 if entry.kind != crate::linkmd_v2::EntryKind::Blob
3685 || entry.child_hash != file.sha256
3686 || entry.bytes != Some(file.bytes)
3687 {
3688 return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3689 }
3690 } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3691 return Err(invalid_feed("v2 file proof traversed a non-directory"));
3692 } else {
3693 directory_root = entry.child_hash.clone();
3694 }
3695 }
3696 Ok(())
3697}
3698
3699fn v2_manifest(
3700 cfg: &HubConfig,
3701 brain: &str,
3702 pointer: Option<&V2PointerBody>,
3703) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3704 let Some(pointer) = pointer else {
3705 return Ok(std::collections::BTreeMap::new());
3706 };
3707 let Some(root) = pointer.content_root.as_deref() else {
3708 return Ok(std::collections::BTreeMap::new());
3709 };
3710 let mut files = std::collections::BTreeMap::new();
3711 let mut after = String::new();
3712 loop {
3713 let encoded_after: String =
3714 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3715 let path = format!(
3716 "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3717 pointer.commit_hash
3718 );
3719 let value = ensure_ok(
3720 request_capped(
3721 cfg,
3722 "GET",
3723 &path,
3724 None,
3725 Auth::Required,
3726 MAX_FEED_RESPONSE_BYTES,
3727 )?,
3728 "v2 file manifest",
3729 )?;
3730 let page: V2ManifestPage = serde_json::from_value(value)
3731 .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3732 if page.v != 2
3733 || page.commit != pointer.commit_hash
3734 || page.content_root.as_deref() != Some(root)
3735 || page.files.len() > 500
3736 {
3737 return Err(invalid_feed(
3738 "v2 file manifest is not bound to the verified head",
3739 ));
3740 }
3741 for file in page.files {
3742 verify_v2_file_proof(root, &file)?;
3743 if files
3744 .insert(
3745 file.path.clone(),
3746 V2BaselineFile {
3747 sha256: file.sha256,
3748 bytes: file.bytes,
3749 proof: Some(file.proof),
3750 },
3751 )
3752 .is_some()
3753 {
3754 return Err(invalid_feed("v2 file manifest repeats a path"));
3755 }
3756 if files.len() > MAX_PUSH_FILES {
3757 return Err(invalid_feed(
3758 "v2 file manifest exceeds the file-count bound",
3759 ));
3760 }
3761 }
3762 match page.next_cursor {
3763 None => break,
3764 Some(next) if next > after => after = next,
3765 Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3766 }
3767 }
3768 Ok(files)
3769}
3770
3771fn v2_manifest_file(
3776 cfg: &HubConfig,
3777 brain: &str,
3778 pointer: &V2PointerBody,
3779 path: &str,
3780) -> LinkResult<Option<V2BaselineFile>> {
3781 let Some(root) = pointer.content_root.as_deref() else {
3782 return Ok(None);
3783 };
3784 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
3785 path: error.to_string(),
3786 })?;
3787 let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
3788 let value = ensure_ok(
3789 request_capped(
3790 cfg,
3791 "GET",
3792 &format!(
3793 "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
3794 pointer.commit_hash
3795 ),
3796 None,
3797 Auth::Required,
3798 MAX_FEED_RESPONSE_BYTES,
3799 )?,
3800 "v2 exact file proof",
3801 )?;
3802 let mut page: V2ManifestPage = serde_json::from_value(value)
3803 .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
3804 if page.v != 2
3805 || page.commit != pointer.commit_hash
3806 || page.content_root.as_deref() != Some(root)
3807 || page.next_cursor.is_some()
3808 || page.files.len() != 1
3809 || page.files[0].path != path
3810 {
3811 return Err(invalid_feed(
3812 "v2 exact file proof is not bound to the requested signed path",
3813 ));
3814 }
3815 let file = page.files.pop().expect("exactly one file was checked");
3816 verify_v2_file_proof(root, &file)?;
3817 Ok(Some(V2BaselineFile {
3818 sha256: file.sha256,
3819 bytes: file.bytes,
3820 proof: Some(file.proof),
3821 }))
3822}
3823
3824fn v2_manifest_file_by_id(
3829 cfg: &HubConfig,
3830 brain: &str,
3831 pointer: &V2PointerBody,
3832 id: &str,
3833) -> LinkResult<(String, V2BaselineFile)> {
3834 let root = pointer
3835 .content_root
3836 .as_deref()
3837 .ok_or_else(|| LinkError::Http {
3838 what: "resolve",
3839 status: 404,
3840 message: "record not found".to_string(),
3841 code: Some("NOT_FOUND".to_string()),
3842 details: None,
3843 })?;
3844 if !crate::ulid::is_ulid(id) {
3845 return Err(LinkError::BadAddress {
3846 given: id.to_string(),
3847 reason: BAD_TARGET_REASON.to_string(),
3848 });
3849 }
3850 let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
3851 let value = ensure_ok(
3852 request_capped(
3853 cfg,
3854 "GET",
3855 &format!(
3856 "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
3857 pointer.commit_hash
3858 ),
3859 None,
3860 Auth::Required,
3861 MAX_FEED_RESPONSE_BYTES,
3862 )?,
3863 "v2 exact id proof",
3864 )?;
3865 let mut page: V2ManifestPage = serde_json::from_value(value)
3866 .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
3867 if page.v != 2
3868 || page.commit != pointer.commit_hash
3869 || page.content_root.as_deref() != Some(root)
3870 || page.next_cursor.is_some()
3871 || page.files.len() != 1
3872 {
3873 return Err(invalid_feed(
3874 "v2 exact id proof is not bound to one signed path",
3875 ));
3876 }
3877 let file = page.files.pop().expect("exactly one file was checked");
3878 if !safe_store_rel_path(&file.path)
3879 || !file.path.ends_with(".md")
3880 || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
3881 {
3882 return Err(invalid_feed("v2 exact id proof has an invalid record path"));
3883 }
3884 verify_v2_file_proof(root, &file)?;
3885 Ok((
3886 file.path,
3887 V2BaselineFile {
3888 sha256: file.sha256,
3889 bytes: file.bytes,
3890 proof: Some(file.proof),
3891 },
3892 ))
3893}
3894
3895fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
3896 crate::linkmd_v2::normalize_path(&item.path)
3897 .map_err(|error| invalid_feed(error.to_string()))?;
3898 if !is_sha256(&item.blob_sha256)
3899 || !is_sha256(&item.leaf_hash)
3900 || item.wrappers.is_empty()
3901 || !matches!(item.disposition.as_str(), "hosted" | "withheld")
3902 || item
3903 .wrappers
3904 .iter()
3905 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
3906 {
3907 return Err(invalid_feed("v2 asset manifest item is invalid"));
3908 }
3909 let leaf = json!({
3910 "blob_sha256": item.blob_sha256,
3911 "bytes": item.bytes,
3912 "disposition": item.disposition,
3913 "media_type": item.media_type,
3914 "path": item.path,
3915 "required": item.required,
3916 "v": 2,
3917 "wrappers": item.wrappers,
3918 });
3919 if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
3920 .map_err(|error| invalid_feed(error.to_string()))?
3921 != item.leaf_hash
3922 || !crate::linkmd_v2::verify_proof_with_domain(
3923 root,
3924 &item.path,
3925 &item.proof,
3926 crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
3927 )
3928 .map_err(|error| invalid_feed(error.to_string()))?
3929 {
3930 return Err(invalid_feed("v2 asset inclusion proof failed"));
3931 }
3932 match &item.proof {
3933 crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
3934 if entry.name == item.path
3935 && entry.kind == crate::linkmd_v2::EntryKind::Blob
3936 && entry.child_hash == item.leaf_hash
3937 && entry.bytes == Some(item.bytes) =>
3938 {
3939 Ok(())
3940 }
3941 _ => Err(invalid_feed(
3942 "v2 asset proof leaf differs from its manifest",
3943 )),
3944 }
3945}
3946
3947fn v2_asset_manifest(
3948 cfg: &HubConfig,
3949 brain: &str,
3950 pointer: Option<&V2PointerBody>,
3951) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
3952 let Some(pointer) = pointer else {
3953 return Ok(std::collections::BTreeMap::new());
3954 };
3955 let Some(root) = pointer.asset_root.as_deref() else {
3956 return Ok(std::collections::BTreeMap::new());
3957 };
3958 let mut assets = std::collections::BTreeMap::new();
3959 let mut after = String::new();
3960 loop {
3961 let encoded_after: String =
3962 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3963 let path = format!(
3964 "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
3965 pointer.commit_hash
3966 );
3967 let value = ensure_ok(
3968 request_capped(
3969 cfg,
3970 "GET",
3971 &path,
3972 None,
3973 Auth::Required,
3974 MAX_FEED_RESPONSE_BYTES,
3975 )?,
3976 "v2 asset manifest",
3977 )?;
3978 let page: V2AssetManifestPage = serde_json::from_value(value)
3979 .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
3980 if page.v != 2
3981 || page.commit != pointer.commit_hash
3982 || page.asset_root.as_deref() != Some(root)
3983 || page.assets.len() > 500
3984 {
3985 return Err(invalid_feed(
3986 "v2 asset manifest is not bound to the verified head",
3987 ));
3988 }
3989 for item in page.assets {
3990 verify_v2_asset_proof(root, &item)?;
3991 let path = item.path.clone();
3992 if assets
3993 .insert(
3994 path,
3995 V2BaselineAsset {
3996 blob_sha256: item.blob_sha256,
3997 bytes: item.bytes,
3998 media_type: item.media_type,
3999 wrappers: item.wrappers,
4000 required: item.required,
4001 disposition: item.disposition,
4002 leaf_hash: item.leaf_hash,
4003 },
4004 )
4005 .is_some()
4006 {
4007 return Err(invalid_feed("v2 asset manifest repeats a path"));
4008 }
4009 if assets.len() > MAX_PUSH_FILES {
4010 return Err(invalid_feed(
4011 "v2 asset manifest exceeds the item-count bound",
4012 ));
4013 }
4014 }
4015 match page.next_cursor {
4016 None => break,
4017 Some(next) if next > after => after = next,
4018 Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4019 }
4020 }
4021 Ok(assets)
4022}
4023
4024fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4025 crate::AssetRecord {
4026 path: path.to_string(),
4027 sha256: asset.blob_sha256.clone(),
4028 bytes: asset.bytes,
4029 media_type: asset.media_type.clone(),
4030 wrappers: asset.wrappers.clone(),
4031 required: asset.required,
4032 }
4033}
4034
4035fn v2_asset_resumes_hosting(
4036 remote: Option<&V2BaselineAsset>,
4037 path: &str,
4038 record: &crate::AssetRecord,
4039 disposition: &str,
4040) -> bool {
4041 remote.is_some_and(|asset| {
4042 asset.disposition == "withheld"
4043 && disposition == "hosted"
4044 && v2_asset_record(asset, path) == *record
4045 })
4046}
4047
4048fn v2_asset_record_manifest_bytes(
4049 assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4050) -> LinkResult<Vec<u8>> {
4051 let mut bytes = Vec::new();
4052 for (path, asset) in assets {
4053 if asset.path != *path {
4054 return Err(invalid_feed(
4055 "local asset manifest key differs from its record path",
4056 ));
4057 }
4058 serde_json::to_writer(&mut bytes, asset)
4059 .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4060 bytes.push(b'\n');
4061 }
4062 Ok(bytes)
4063}
4064
4065fn v2_local_asset_records(
4066 store: &Store,
4067) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4068 Ok(crate::assets::read_manifest(store)
4069 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
4070 .into_iter()
4071 .map(|asset| (asset.path.clone(), asset))
4072 .collect())
4073}
4074
4075fn v2_asset_records_match_remote(
4076 local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4077 remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4078) -> bool {
4079 local.len() == remote.len()
4080 && remote
4081 .iter()
4082 .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4083}
4084
4085#[derive(Debug, Clone, PartialEq, Eq)]
4086struct V2PulledMerge<T> {
4087 records: std::collections::BTreeMap<String, T>,
4088 accept_remote: std::collections::BTreeSet<String>,
4089 conflicts: Vec<String>,
4090}
4091
4092fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4098 base: &std::collections::BTreeMap<String, Base>,
4099 remote: &std::collections::BTreeMap<String, Remote>,
4100 local: &std::collections::BTreeMap<String, Record>,
4101 base_record: BaseRecord,
4102 remote_record: RemoteRecord,
4103 keep_local: KeepLocal,
4104) -> V2PulledMerge<Record>
4105where
4106 Record: Clone + Eq,
4107 BaseRecord: Fn(&Base, &str) -> Record,
4108 RemoteRecord: Fn(&Remote, &str) -> Record,
4109 KeepLocal: Fn(&str) -> bool,
4110{
4111 let paths = base
4112 .keys()
4113 .chain(remote.keys())
4114 .chain(local.keys())
4115 .cloned()
4116 .collect::<std::collections::BTreeSet<_>>();
4117 let mut records = local.clone();
4118 let mut accept_remote = std::collections::BTreeSet::new();
4119 let mut conflicts = Vec::new();
4120 for path in paths {
4121 if keep_local(&path) {
4122 continue;
4123 }
4124 let base_value = base.get(&path).map(|value| base_record(value, &path));
4125 let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4126 let local_value = local.get(&path).cloned();
4127 if local_value != base_value && remote_value != base_value && local_value != remote_value {
4128 conflicts.push(path);
4129 continue;
4130 }
4131 if local_value == base_value || local_value == remote_value {
4132 accept_remote.insert(path.clone());
4133 match remote_value {
4134 Some(value) => {
4135 records.insert(path, value);
4136 }
4137 None => {
4138 records.remove(&path);
4139 }
4140 }
4141 }
4142 }
4143 V2PulledMerge {
4144 records,
4145 accept_remote,
4146 conflicts,
4147 }
4148}
4149
4150fn sign_verified_v2_candidate(
4151 cfg: &HubConfig,
4152 head: &V2VerifiedHead,
4153 expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4154 expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4155 mutation_id: &str,
4156 request_body: &Value,
4157 challenge_value: &Value,
4158) -> LinkResult<(String, String, String)> {
4159 if head.view_kind != "full" {
4160 return Err(invalid_feed(
4161 "a scoped self-custody writer must use the proposal workflow",
4162 ));
4163 }
4164 if head.identity.custody != "self" {
4165 return Err(invalid_feed(
4166 "a hub-custodied brain unexpectedly requested an external signature",
4167 ));
4168 }
4169 let key = cfg
4170 .brain_key
4171 .as_ref()
4172 .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4173 if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4174 || key.public_key_spki != head.identity.public_key_spki
4175 {
4176 return Err(bad_agent_key(
4177 "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4178 ));
4179 }
4180 let challenge_id = challenge_value
4181 .get("id")
4182 .and_then(Value::as_str)
4183 .filter(|id| crate::ulid::is_ulid(id))
4184 .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4185 let expected_endpoint = format!(
4186 "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4187 head.brain_id
4188 );
4189 if challenge_value
4190 .get("candidate_endpoint")
4191 .and_then(Value::as_str)
4192 != Some(expected_endpoint.as_str())
4193 {
4194 return Err(invalid_feed(
4195 "self-custody challenge candidate endpoint is not origin-bound",
4196 ));
4197 }
4198
4199 let mut files = std::collections::BTreeMap::new();
4200 let mut after = String::new();
4201 type CandidateCoordinate = (
4202 String,
4203 String,
4204 String,
4205 String,
4206 Option<String>,
4207 Option<String>,
4208 u64,
4209 Option<String>,
4210 );
4211 let mut pinned: Option<CandidateCoordinate> = None;
4212 loop {
4213 let encoded_after: String =
4214 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4215 let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4216 let value = ensure_ok(
4217 request_capped(
4218 cfg,
4219 "GET",
4220 &path,
4221 None,
4222 Auth::Required,
4223 MAX_FEED_RESPONSE_BYTES,
4224 )?,
4225 "v2 self-custody candidate",
4226 )?;
4227 let page: V2SigningCandidatePage = serde_json::from_value(value)
4228 .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4229 if page.v != 2
4230 || page.challenge_id != challenge_id
4231 || page.mutation_id != mutation_id
4232 || page.candidate.seq != page.parent.seq + 1
4233 || page.files.len() > 500
4234 || page.expires_at.is_empty()
4235 {
4236 return Err(invalid_feed(
4237 "self-custody candidate is not bound to this mutation",
4238 ));
4239 }
4240 let coordinate = (
4241 page.request_hash.clone(),
4242 page.candidate.signing_bytes_base64.clone(),
4243 page.candidate.changes_base64.clone(),
4244 page.candidate.actor_claim_base64.clone(),
4245 page.candidate.content_root.clone(),
4246 page.candidate.asset_root.clone(),
4247 page.parent.seq,
4248 page.parent.commit_hash.clone(),
4249 );
4250 if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4251 return Err(invalid_feed(
4252 "self-custody candidate changed between manifest pages",
4253 ));
4254 }
4255 pinned = Some(coordinate);
4256 let root = page
4257 .candidate
4258 .content_root
4259 .as_deref()
4260 .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4261 for file in page.files {
4262 verify_v2_file_proof(root, &file)?;
4263 if files
4264 .insert(
4265 file.path.clone(),
4266 V2BaselineFile {
4267 sha256: file.sha256,
4268 bytes: file.bytes,
4269 proof: Some(file.proof),
4270 },
4271 )
4272 .is_some()
4273 {
4274 return Err(invalid_feed(
4275 "self-custody candidate repeats a manifest path",
4276 ));
4277 }
4278 if files.len() > MAX_PUSH_FILES {
4279 return Err(invalid_feed(
4280 "self-custody candidate exceeds the file-count bound",
4281 ));
4282 }
4283 }
4284 match page.next_cursor {
4285 None => break,
4286 Some(next) if next > after => after = next,
4287 Some(_) => {
4288 return Err(invalid_feed(
4289 "self-custody candidate cursor did not advance",
4290 ))
4291 }
4292 }
4293 }
4294 if files.len() != expected.len()
4295 || files.iter().any(|(path, file)| {
4296 expected.get(path).is_none_or(|expected| {
4297 expected.sha256 != file.sha256 || expected.bytes != file.bytes
4298 })
4299 })
4300 {
4301 return Err(invalid_feed(
4302 "self-custody candidate contains an unexpected file mutation",
4303 ));
4304 }
4305 let mut assets = std::collections::BTreeMap::new();
4306 after.clear();
4307 loop {
4308 let encoded_after: String =
4309 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4310 let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4311 let value = ensure_ok(
4312 request_capped(
4313 cfg,
4314 "GET",
4315 &path,
4316 None,
4317 Auth::Required,
4318 MAX_FEED_RESPONSE_BYTES,
4319 )?,
4320 "v2 self-custody asset candidate",
4321 )?;
4322 let page: V2SigningCandidatePage = serde_json::from_value(value)
4323 .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4324 let coordinate = (
4325 page.request_hash.clone(),
4326 page.candidate.signing_bytes_base64.clone(),
4327 page.candidate.changes_base64.clone(),
4328 page.candidate.actor_claim_base64.clone(),
4329 page.candidate.content_root.clone(),
4330 page.candidate.asset_root.clone(),
4331 page.parent.seq,
4332 page.parent.commit_hash.clone(),
4333 );
4334 if page.v != 2
4335 || page.challenge_id != challenge_id
4336 || page.mutation_id != mutation_id
4337 || page.assets.len() > 500
4338 || pinned.as_ref() != Some(&coordinate)
4339 {
4340 return Err(invalid_feed(
4341 "self-custody asset candidate changed or is not bound",
4342 ));
4343 }
4344 let root = page.candidate.asset_root.as_deref();
4345 if !page.assets.is_empty() && root.is_none() {
4346 return Err(invalid_feed("asset candidate has no asset root"));
4347 }
4348 for item in page.assets {
4349 verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4350 if assets
4351 .insert(
4352 item.path.clone(),
4353 V2BaselineAsset {
4354 blob_sha256: item.blob_sha256,
4355 bytes: item.bytes,
4356 media_type: item.media_type,
4357 wrappers: item.wrappers,
4358 required: item.required,
4359 disposition: item.disposition,
4360 leaf_hash: item.leaf_hash,
4361 },
4362 )
4363 .is_some()
4364 {
4365 return Err(invalid_feed("self-custody candidate repeats an asset"));
4366 }
4367 }
4368 match page.next_cursor {
4369 None => break,
4370 Some(next) if next > after => after = next,
4371 Some(_) => {
4372 return Err(invalid_feed(
4373 "self-custody asset candidate cursor did not advance",
4374 ))
4375 }
4376 }
4377 }
4378 if assets.len() != expected_assets.len()
4379 || assets.iter().any(|(path, asset)| {
4380 expected_assets.get(path).is_none_or(|expected| {
4381 asset.blob_sha256 != expected.blob_sha256
4382 || asset.bytes != expected.bytes
4383 || asset.media_type != expected.media_type
4384 || asset.wrappers != expected.wrappers
4385 || asset.required != expected.required
4386 || asset.disposition != expected.disposition
4387 })
4388 })
4389 {
4390 return Err(invalid_feed(
4391 "self-custody candidate contains an unexpected asset mutation",
4392 ));
4393 }
4394 let Some((
4395 request_hash,
4396 signing_b64,
4397 changes_b64,
4398 actor_b64,
4399 root,
4400 asset_root,
4401 parent_seq,
4402 parent,
4403 )) = pinned
4404 else {
4405 return Err(invalid_feed("self-custody candidate has no manifest"));
4406 };
4407 let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4408 let current_commit = head
4409 .pointer
4410 .as_ref()
4411 .map(|pointer| pointer.commit_hash.clone());
4412 if parent_seq != current_seq || parent != current_commit {
4413 return Err(LinkError::RemoteAdvancedDuringSync);
4414 }
4415 let changes = STANDARD
4416 .decode(changes_b64)
4417 .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4418 let mut expected_changes = json!({
4419 "mutation_id": mutation_id,
4420 "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4421 "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4422 "v": 2,
4423 });
4424 if let Some(withheld_links) = request_body.get("withheld_links") {
4425 expected_changes["withheld_links"] = withheld_links.clone();
4426 }
4427 if let Some(checkout_id) = request_body.get("checkout_id") {
4428 expected_changes["checkout_id"] = checkout_id.clone();
4429 }
4430 let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4431 .map_err(|error| invalid_feed(error.to_string()))?;
4432 if changes != expected_changes_bytes {
4433 return Err(invalid_feed(
4434 "self-custody changeset differs from the requested mutation",
4435 ));
4436 }
4437 let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4438 .map_err(|error| invalid_feed(error.to_string()))?;
4439 let request_value = json!({
4440 "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4441 "brain": head.brain_id,
4442 "changes_sha256": changes_hash,
4443 "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4444 "v": 2,
4445 "v1_bridge": Value::Null,
4446 });
4447 let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4448 .map_err(|error| invalid_feed(error.to_string()))?;
4449 if request_hash != expected_request_hash {
4450 return Err(invalid_feed(
4451 "self-custody request hash differs from the requested mutation",
4452 ));
4453 }
4454 let actor = STANDARD
4455 .decode(actor_b64)
4456 .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4457 let actor_value: Value = serde_json::from_slice(&actor)
4458 .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4459 if crate::linkmd_v2::canonical_bytes(&actor_value)
4460 .map_err(|error| invalid_feed(error.to_string()))?
4461 != actor
4462 {
4463 return Err(invalid_feed("self-custody actor claim is not canonical"));
4464 }
4465 let actor_object = actor_value
4466 .as_object()
4467 .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4468 let actor_claim = actor_object
4469 .get("claim")
4470 .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4471 let actor_public_key = actor_object
4472 .get("public_key")
4473 .and_then(Value::as_str)
4474 .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4475 let actor_fingerprint = actor_object
4476 .get("fingerprint")
4477 .and_then(Value::as_str)
4478 .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4479 let actor_signature = actor_object
4480 .get("sig")
4481 .and_then(Value::as_str)
4482 .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4483 let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4484 .map_err(|error| invalid_feed(error.to_string()))?;
4485 let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4486 let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4487 let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4488 let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4489 let impact = actor_claim
4490 .get("result")
4491 .and_then(|result| result.get("impact"))
4492 .and_then(Value::as_object);
4493 let impact_fields = [
4494 "creates",
4495 "updates",
4496 "deletes",
4497 "withdrawals",
4498 "renames",
4499 "restores",
4500 "asset_changes",
4501 "public_expansions",
4502 "executable_activations",
4503 ];
4504 let impact_is_valid = impact.is_some_and(|impact| {
4505 impact.len() == impact_fields.len() + 1
4506 && impact.get("v").and_then(Value::as_u64) == Some(1)
4507 && impact_fields
4508 .iter()
4509 .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4510 });
4511 if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4512 || head
4513 .trust
4514 .hub_signer
4515 .as_ref()
4516 .is_some_and(|known| known != &expected_actor_signer)
4517 || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4518 || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4519 || actor_claim
4520 .get("candidate")
4521 .and_then(|candidate| candidate.get("changes_sha256"))
4522 .and_then(Value::as_str)
4523 != Some(changes_hash.as_str())
4524 || actor_claim
4525 .get("candidate")
4526 .and_then(|candidate| candidate.get("state_root"))
4527 != Some(&expected_actor_root)
4528 || actor_claim
4529 .get("candidate")
4530 .and_then(|candidate| candidate.get("asset_root"))
4531 != Some(&expected_actor_asset_root)
4532 || actor_claim
4533 .get("candidate")
4534 .and_then(|candidate| candidate.get("control_revision"))
4535 .and_then(Value::as_str)
4536 != Some(head.control_revision.as_str())
4537 || !impact_is_valid
4538 {
4539 return Err(invalid_feed(
4540 "self-custody actor claim does not bind the verified authority",
4541 ));
4542 }
4543 let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4544 .map_err(|error| invalid_feed(error.to_string()))?;
4545 let signing = STANDARD
4546 .decode(signing_b64)
4547 .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4548 let signing_value: Value = serde_json::from_slice(&signing)
4549 .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4550 if crate::linkmd_v2::canonical_bytes(&signing_value)
4551 .map_err(|error| invalid_feed(error.to_string()))?
4552 != signing
4553 {
4554 return Err(invalid_feed("self-custody signing bytes are not canonical"));
4555 }
4556 let pointer = head.pointer.as_ref();
4557 let expected_materializer = pointer
4558 .map(|value| value.materializer.as_str())
4559 .unwrap_or("dbmd-projection-v1");
4560 let expected_parent_commit = request_body
4561 .get("base")
4562 .and_then(|base| base.get("commit_hash"))
4563 .cloned()
4564 .unwrap_or(Value::Null);
4565 let expected_parent_root = request_body
4566 .get("base")
4567 .and_then(|base| base.get("content_root"))
4568 .cloned()
4569 .unwrap_or(Value::Null);
4570 let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4571 let expected_parent_asset_root = request_body
4572 .get("base")
4573 .and_then(|base| base.get("asset_root"))
4574 .cloned()
4575 .unwrap_or(Value::Null);
4576 let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4577 let expected_prev_entry = pointer
4578 .map(|value| Value::String(value.feed_hash.clone()))
4579 .unwrap_or(Value::Null);
4580 let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4581 .map_err(|_| invalid_feed("brain identity history is too large"))?
4582 + 1;
4583 if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4584 || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4585 || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4586 || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4587 || signing_value.get("public_key").and_then(Value::as_str)
4588 != Some(key.public_key_spki.as_str())
4589 || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4590 || signing_value.get("parent_root") != Some(&expected_parent_root)
4591 || signing_value.get("state_root") != Some(&expected_state_root)
4592 || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4593 || signing_value.get("asset_root") != Some(&expected_asset_root)
4594 || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4595 || signing_value.get("changes_sha256").and_then(Value::as_str)
4596 != Some(changes_hash.as_str())
4597 || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4598 || signing_value
4599 .get("control_revision")
4600 .and_then(Value::as_str)
4601 != Some(head.control_revision.as_str())
4602 || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4603 || signing_value.get("v1_bridge") != Some(&Value::Null)
4604 || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4605 {
4606 return Err(invalid_feed(
4607 "self-custody signing bytes do not bind the verified candidate",
4608 ));
4609 }
4610 let pair = agent_keypair(&key.pkcs8)?;
4611 let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4612 Ok((challenge_id.to_string(), signature, expected_actor_signer))
4613}
4614
4615fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4616 let origin = normalized_origin(&cfg.hub)?;
4617 let absolute = if checkout.is_absolute() {
4618 checkout.to_path_buf()
4619 } else {
4620 std::env::current_dir()?.join(checkout)
4621 };
4622 Ok(format!(
4623 "sync-{}.json",
4624 content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4625 ))
4626}
4627
4628fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4629 if let Some(value) = existing {
4630 if !is_sha256(value) {
4631 return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4632 }
4633 return Ok(value.to_string());
4634 }
4635 use ring::rand::SecureRandom as _;
4636 let mut random = [0_u8; 32];
4637 ring::rand::SystemRandom::new()
4638 .fill(&mut random)
4639 .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4640 Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4641}
4642
4643#[cfg(any(unix, windows))]
4644fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4645 let directory = open_trust_dir(cfg)?;
4646 let origin = normalized_origin(&cfg.hub)?;
4647 let name = format!(
4648 "operation-{}.lock",
4649 content_sha256(format!("{origin}\0{brain}").as_bytes())
4650 );
4651 lock_trust_name(&directory, &name)
4652}
4653
4654#[cfg(not(any(unix, windows)))]
4655fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4656 Err(LinkError::UnsupportedPlatform {
4657 operation: "serialized link.md v2 sync",
4658 })
4659}
4660
4661fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4662 left.brain_id == right.brain_id
4663 && left.view_kind == right.view_kind
4664 && left.view_revision == right.view_revision
4665 && left.control_revision == right.control_revision
4666 && match (&left.pointer, &right.pointer) {
4667 (None, None) => true,
4668 (Some(left), Some(right)) => {
4669 left.seq == right.seq
4670 && left.commit_hash == right.commit_hash
4671 && left.content_root == right.content_root
4672 && left.asset_root == right.asset_root
4673 && left.feed_hash == right.feed_hash
4674 }
4675 _ => false,
4676 }
4677}
4678
4679fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4680 format!(
4681 "---\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"
4682 )
4683 .into_bytes()
4684}
4685
4686fn scoped_projection_sha256(brain: &str) -> String {
4687 content_sha256(&scoped_projection_bytes(brain))
4688}
4689
4690#[derive(Deserialize)]
4691struct LocalScopedViewMarker {
4692 v: u8,
4693 kind: String,
4694 authoritative: bool,
4695 brain: String,
4696 projection_sha256: String,
4697}
4698
4699pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4703 let marker = store
4704 .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4705 .ok()
4706 .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4707 let Some(marker) = marker else {
4708 return false;
4709 };
4710 if marker.v != 1
4711 || marker.kind != "link.md-scoped-view"
4712 || marker.authoritative
4713 || !crate::ulid::is_ulid(&marker.brain)
4714 || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4715 {
4716 return false;
4717 }
4718 store
4719 .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4720 .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4721}
4722
4723fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4724 let mut bytes = serde_json::to_vec_pretty(&json!({
4725 "v": 1,
4726 "kind": "link.md-scoped-view",
4727 "authoritative": false,
4728 "brain": head.brain_id,
4729 "view_revision": head.view_revision,
4730 "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4731 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4732 "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4733 "visible_files": files,
4734 "projection_sha256": scoped_projection_sha256(&head.brain_id),
4735 }))
4736 .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4737 bytes.push(b'\n');
4738 Ok(bytes)
4739}
4740
4741fn refresh_scoped_view_marker(
4742 store: &Store,
4743 head: &V2VerifiedHead,
4744 files: usize,
4745) -> LinkResult<()> {
4746 if head.view_kind == "scoped" {
4747 store.write_atomic(
4748 Path::new(".dbmd/view.json"),
4749 &scoped_view_metadata(head, files)?,
4750 )?;
4751 }
4752 Ok(())
4753}
4754
4755fn ensure_v2_view_compatible(
4756 head: &V2VerifiedHead,
4757 baseline: Option<&V2SyncBaseline>,
4758) -> LinkResult<()> {
4759 let Some(baseline) = baseline else {
4760 return Ok(());
4761 };
4762 match (
4763 baseline.view_kind.as_deref(),
4764 baseline.view_revision.as_deref(),
4765 ) {
4766 (None, None) if head.view_kind == "full" => Ok(()),
4767 (Some(kind), Some(revision))
4768 if kind == head.view_kind && revision == head.view_revision =>
4769 {
4770 Ok(())
4771 }
4772 _ => Err(LinkError::ScopedViewChanged),
4773 }
4774}
4775
4776fn ensure_established_v2_checkout_opened(
4777 head: &V2VerifiedHead,
4778 baseline: Option<&V2SyncBaseline>,
4779 opened: bool,
4780) -> LinkResult<()> {
4781 if baseline.is_none() || opened {
4782 return Ok(());
4783 }
4784 if head.view_kind == "scoped" {
4785 return Err(LinkError::ScopedProjectionModified);
4786 }
4787 Err(LinkError::InvalidPack {
4788 message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4789 })
4790}
4791
4792fn remove_scoped_projection(
4793 head: &V2VerifiedHead,
4794 baseline: Option<&V2SyncBaseline>,
4795 view: &mut V2LocalView,
4796) -> LinkResult<()> {
4797 if head.view_kind != "scoped" {
4798 return Ok(());
4799 }
4800 let expected = scoped_projection_sha256(&head.brain_id);
4801 if baseline
4802 .and_then(|state| state.projection_sha256.as_deref())
4803 .is_some_and(|pinned| pinned != expected)
4804 {
4805 return Err(LinkError::ScopedViewChanged);
4806 }
4807 if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4808 return Err(LinkError::ScopedProjectionModified);
4809 }
4810 view.riding.remove("DB.md");
4811 view.eligibility.remove("DB.md");
4812 Ok(())
4813}
4814
4815fn local_view_for_v2_push(
4816 store: &Store,
4817 head: &V2VerifiedHead,
4818 baseline: Option<&V2SyncBaseline>,
4819 carried: Option<V2LocalView>,
4820) -> LinkResult<V2LocalView> {
4821 match carried {
4822 Some(view) => Ok(view),
4827 None => {
4828 let mut view = v2_local_files(store)?;
4829 remove_scoped_projection(head, baseline, &mut view)?;
4830 Ok(view)
4831 }
4832 }
4833}
4834
4835fn files_for_v2_view(
4836 head: &V2VerifiedHead,
4837 mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4838) -> std::collections::BTreeMap<String, V2BaselineFile> {
4839 if head.view_kind == "scoped" {
4840 files.remove("DB.md");
4844 }
4845 files
4846}
4847
4848fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4849 let baseline: V2SyncBaseline =
4850 serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
4851 if baseline.v != 2
4852 || baseline.origin != normalized_origin(&cfg.hub)?
4853 || baseline.brain != brain
4854 || baseline
4855 .commit_hash
4856 .as_deref()
4857 .is_some_and(|hash| !is_sha256(hash))
4858 || baseline
4859 .content_root
4860 .as_deref()
4861 .is_some_and(|hash| !is_sha256(hash))
4862 || baseline
4863 .asset_root
4864 .as_deref()
4865 .is_some_and(|hash| !is_sha256(hash))
4866 || baseline
4867 .local_policy_digest
4868 .as_deref()
4869 .is_some_and(|hash| !is_sha256(hash))
4870 || baseline
4871 .view_kind
4872 .as_deref()
4873 .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
4874 || baseline
4875 .view_revision
4876 .as_deref()
4877 .is_some_and(|hash| !is_sha256(hash))
4878 || baseline
4879 .projection_sha256
4880 .as_deref()
4881 .is_some_and(|hash| !is_sha256(hash))
4882 || (baseline.view_kind.as_deref() == Some("scoped")
4883 && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
4884 || baseline.files.len() > MAX_PUSH_FILES
4885 || baseline.assets.len() > MAX_PUSH_FILES
4886 || baseline.local_eligibility.len() > MAX_PUSH_FILES
4887 || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
4888 || baseline.files.iter().any(|(path, file)| {
4889 crate::linkmd_v2::normalize_path(path).is_err()
4890 || !is_sha256(&file.sha256)
4891 || file.bytes > MAX_STORE_BYTES
4892 })
4893 || baseline.assets.iter().any(|(path, asset)| {
4894 crate::linkmd_v2::normalize_path(path).is_err()
4895 || !is_sha256(&asset.blob_sha256)
4896 || !is_sha256(&asset.leaf_hash)
4897 || asset.bytes > MAX_STORE_BYTES
4898 || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
4899 || asset.wrappers.is_empty()
4900 || asset
4901 .wrappers
4902 .iter()
4903 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4904 })
4905 || baseline
4906 .local_eligibility
4907 .keys()
4908 .chain(baseline.remote_copy_remains.keys())
4909 .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
4910 || baseline
4911 .remote_copy_remains
4912 .values()
4913 .any(|hash| !is_sha256(hash))
4914 || baseline
4915 .checkout_id
4916 .as_deref()
4917 .is_some_and(|checkout_id| !is_sha256(checkout_id))
4918 {
4919 return Err(invalid_feed("v2 sync baseline failed validation"));
4920 }
4921 Ok(baseline)
4922}
4923
4924#[cfg(unix)]
4925fn load_v2_baseline(
4926 cfg: &HubConfig,
4927 brain: &str,
4928 checkout: &Path,
4929) -> LinkResult<Option<V2SyncBaseline>> {
4930 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4931 let directory = open_trust_dir(cfg)?;
4932 let name_string = v2_baseline_name(cfg, brain, checkout)?;
4933 let _lock = lock_trust_name(&directory, &name_string)?;
4934 let name = c_name(name_string.as_bytes(), &name_string)?;
4935 let fd = unsafe {
4936 libc::openat(
4937 directory.as_raw_fd(),
4938 name.as_ptr(),
4939 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4940 )
4941 };
4942 if fd < 0 {
4943 let error = std::io::Error::last_os_error();
4944 return if error.kind() == std::io::ErrorKind::NotFound {
4945 Ok(None)
4946 } else {
4947 Err(LinkError::UnsafePath { path: name_string })
4948 };
4949 }
4950 let file = unsafe { std::fs::File::from_raw_fd(fd) };
4951 let mut bytes = Vec::new();
4952 file.take(MAX_FEED_RESPONSE_BYTES + 1)
4953 .read_to_end(&mut bytes)?;
4954 if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
4955 return Err(invalid_feed("v2 sync baseline is oversized"));
4956 }
4957 Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
4958}
4959
4960#[cfg(windows)]
4961fn load_v2_baseline(
4962 cfg: &HubConfig,
4963 brain: &str,
4964 checkout: &Path,
4965) -> LinkResult<Option<V2SyncBaseline>> {
4966 let directory = open_trust_dir(cfg)?;
4967 let name = v2_baseline_name(cfg, brain, checkout)?;
4968 let _lock = lock_trust_name(&directory, &name)?;
4969 let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
4970 match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
4971 Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
4972 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
4973 Err(_) => Err(LinkError::UnsafePath { path: name }),
4974 }
4975}
4976
4977#[cfg(not(any(unix, windows)))]
4978fn load_v2_baseline(
4979 _cfg: &HubConfig,
4980 _brain: &str,
4981 _checkout: &Path,
4982) -> LinkResult<Option<V2SyncBaseline>> {
4983 Err(LinkError::UnsupportedPlatform {
4984 operation: "verified link.md v2 baseline",
4985 })
4986}
4987
4988#[cfg(unix)]
4989fn save_v2_baseline(
4990 cfg: &HubConfig,
4991 brain: &str,
4992 checkout: &Path,
4993 baseline: &V2SyncBaseline,
4994) -> LinkResult<()> {
4995 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4996 let directory = open_trust_dir(cfg)?;
4997 let name_string = v2_baseline_name(cfg, brain, checkout)?;
4998 let _lock = lock_trust_name(&directory, &name_string)?;
4999 let name = c_name(name_string.as_bytes(), &name_string)?;
5000 let mut bytes = serde_json::to_vec(baseline)
5001 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5002 bytes.push(b'\n');
5003 let temp_string = format!(
5004 ".{name_string}.tmp.{}-{}",
5005 std::process::id(),
5006 std::time::SystemTime::now()
5007 .duration_since(std::time::UNIX_EPOCH)
5008 .unwrap_or_default()
5009 .as_nanos()
5010 );
5011 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5012 let fd = unsafe {
5013 libc::openat(
5014 directory.as_raw_fd(),
5015 temp.as_ptr(),
5016 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5017 0o600,
5018 )
5019 };
5020 if fd < 0 {
5021 return Err(std::io::Error::last_os_error().into());
5022 }
5023 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5024 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5025 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5026 return Err(error.into());
5027 }
5028 drop(file);
5029 if unsafe {
5030 libc::renameat(
5031 directory.as_raw_fd(),
5032 temp.as_ptr(),
5033 directory.as_raw_fd(),
5034 name.as_ptr(),
5035 )
5036 } != 0
5037 {
5038 let error = std::io::Error::last_os_error();
5039 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5040 return Err(error.into());
5041 }
5042 directory.sync_all()?;
5043 Ok(())
5044}
5045
5046#[cfg(windows)]
5047fn save_v2_baseline(
5048 cfg: &HubConfig,
5049 brain: &str,
5050 checkout: &Path,
5051 baseline: &V2SyncBaseline,
5052) -> LinkResult<()> {
5053 let directory = open_trust_dir(cfg)?;
5054 let name = v2_baseline_name(cfg, brain, checkout)?;
5055 let _lock = lock_trust_name(&directory, &name)?;
5056 let mut bytes = serde_json::to_vec(baseline)
5057 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5058 bytes.push(b'\n');
5059 crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5060 Ok(())
5061}
5062
5063#[cfg(not(any(unix, windows)))]
5064fn save_v2_baseline(
5065 _cfg: &HubConfig,
5066 _brain: &str,
5067 _checkout: &Path,
5068 _baseline: &V2SyncBaseline,
5069) -> LinkResult<()> {
5070 Err(LinkError::UnsupportedPlatform {
5071 operation: "verified link.md v2 baseline",
5072 })
5073}
5074
5075fn v2_baseline_from_head(
5076 cfg: &HubConfig,
5077 head: &V2VerifiedHead,
5078 files: std::collections::BTreeMap<String, V2BaselineFile>,
5079 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5080 local: Option<&V2LocalView>,
5081 checkout_id: Option<&str>,
5082) -> LinkResult<V2SyncBaseline> {
5083 let mut local_eligibility = local
5084 .map(|view| view.eligibility.clone())
5085 .unwrap_or_default();
5086 if let Some(view) = local {
5087 for path in files.keys() {
5088 local_eligibility
5089 .entry(path.clone())
5090 .or_insert_with(|| !view.policy.keeps_home(path));
5091 }
5092 }
5093 let remote_copy_remains = local_eligibility
5094 .iter()
5095 .filter(|(_, riding)| !**riding)
5096 .filter_map(|(path, _)| {
5097 files
5098 .get(path)
5099 .map(|file| (path.clone(), file.sha256.clone()))
5100 })
5101 .collect();
5102 Ok(V2SyncBaseline {
5103 v: 2,
5104 origin: normalized_origin(&cfg.hub)?,
5105 brain: head.brain_id.clone(),
5106 checkout_id: Some(v2_checkout_id(checkout_id)?),
5107 head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5108 commit_hash: head
5109 .pointer
5110 .as_ref()
5111 .map(|pointer| pointer.commit_hash.clone()),
5112 content_root: head
5113 .pointer
5114 .as_ref()
5115 .and_then(|pointer| pointer.content_root.clone()),
5116 asset_root: head
5117 .pointer
5118 .as_ref()
5119 .and_then(|pointer| pointer.asset_root.clone()),
5120 assets,
5121 view_kind: Some(head.view_kind.clone()),
5122 view_revision: Some(head.view_revision.clone()),
5123 projection_sha256: (head.view_kind == "scoped")
5124 .then(|| scoped_projection_sha256(&head.brain_id)),
5125 files,
5126 local_policy_digest: local.map(|view| view.policy.digest.clone()),
5127 local_eligibility,
5128 remote_copy_remains,
5129 })
5130}
5131
5132fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5133 let policy = crate::linkmd_sync_policy::load(store)
5134 .map_err(|message| LinkError::InvalidPack { message })?;
5135 let asset_paths = crate::assets::read_manifest(store)
5136 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5137 .into_iter()
5138 .map(|asset| asset.path)
5139 .collect::<std::collections::BTreeSet<_>>();
5140 let mut result = std::collections::BTreeMap::new();
5141 let mut eligibility = std::collections::BTreeMap::new();
5142 let mut riding_links = Vec::<(String, Vec<String>)>::new();
5143 let mut total = 0_u64;
5144 let mut paths = vec![PathBuf::from("DB.md")];
5145 paths.extend(store.walk()?);
5146 for relative in paths {
5147 let path = relative.to_string_lossy().replace('\\', "/");
5148 if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5150 continue;
5151 }
5152 if asset_paths.contains(&path) {
5153 continue;
5154 }
5155 crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5156 path: error.to_string(),
5157 })?;
5158 let riding = !policy.keeps_home(&path);
5159 eligibility.insert(path.clone(), riding);
5160 if !riding {
5161 continue;
5162 }
5163 let remaining = MAX_STORE_BYTES.saturating_sub(total);
5164 let bytes = store.read_bounded(&relative, remaining)?;
5165 total = total
5166 .checked_add(bytes.len() as u64)
5167 .ok_or_else(|| LinkError::PushTooLarge {
5168 detail: "v2 local byte count overflow".to_string(),
5169 })?;
5170 if total > MAX_STORE_BYTES {
5171 return Err(LinkError::PushTooLarge {
5172 detail: format!("{total} uncompressed bytes"),
5173 });
5174 }
5175 if std::str::from_utf8(&bytes).is_err() {
5176 return Err(LinkError::NotUtf8 { path });
5177 }
5178 let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5179 riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
5180 result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
5181 }
5182 let kept_home = eligibility
5183 .iter()
5184 .filter(|(_, riding)| !**riding)
5185 .map(|(path, _)| path.clone())
5186 .collect::<std::collections::BTreeSet<_>>();
5187 let mut withheld_links = riding_links
5188 .into_iter()
5189 .flat_map(|(source, targets)| {
5190 let kept_home = &kept_home;
5191 let policy = &policy;
5192 targets.into_iter().filter_map(move |target| {
5193 let target = format!("{target}.md");
5194 (kept_home.contains(&target) || policy.keeps_home(&target)).then_some(
5204 V2WithheldLink {
5205 source: source.clone(),
5206 target,
5207 },
5208 )
5209 })
5210 })
5211 .collect::<Vec<_>>();
5212 withheld_links.sort();
5213 withheld_links.dedup();
5214 Ok(V2LocalView {
5215 riding: result,
5216 eligibility,
5217 policy,
5218 withheld_links,
5219 })
5220}
5221
5222#[derive(Debug, Deserialize)]
5223struct V2DownloadItem {
5224 path: String,
5225 sha256: String,
5226 bytes: u64,
5227 url: String,
5228 method: String,
5229}
5230
5231#[derive(Debug, Deserialize)]
5232struct V2DownloadWindow {
5233 v: u8,
5234 commit: String,
5235 downloads: Vec<V2DownloadItem>,
5236}
5237
5238#[derive(Debug, Deserialize)]
5239struct V2BulkStreamHeader {
5240 v: u8,
5241 path: String,
5242 sha256: String,
5243 bytes: u64,
5244}
5245
5246fn parse_v2_bulk_stream(
5247 bytes: &[u8],
5248 expected: &[(&String, &V2BaselineFile)],
5249) -> LinkResult<Vec<(String, Vec<u8>)>> {
5250 if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5251 return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5252 }
5253 let mut cursor = V2_BULK_STREAM_MAGIC.len();
5254 let mut result = Vec::with_capacity(expected.len());
5255 for (expected_path, expected_file) in expected {
5256 let length_bytes = bytes
5257 .get(cursor..cursor + 4)
5258 .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5259 cursor += 4;
5260 let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5261 if header_len == 0 || header_len > 4 * 1024 {
5262 return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5263 }
5264 let header_bytes = bytes
5265 .get(cursor..cursor + header_len)
5266 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5267 cursor += header_len;
5268 let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5269 .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5270 if header.v != 2
5271 || &header.path != *expected_path
5272 || header.sha256 != expected_file.sha256
5273 || header.bytes != expected_file.bytes
5274 || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5275 {
5276 return Err(invalid_feed(
5277 "v2 bulk stream frame differs from its proven manifest entry",
5278 ));
5279 }
5280 let body_len = usize::try_from(header.bytes)
5281 .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5282 let body = bytes
5283 .get(cursor..cursor + body_len)
5284 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5285 cursor += body_len;
5286 if content_sha256(body) != header.sha256 {
5287 return Err(invalid_feed(
5288 "v2 bulk stream file differs from its proven manifest entry",
5289 ));
5290 }
5291 result.push((header.path, body.to_vec()));
5292 }
5293 if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5294 return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5295 }
5296 cursor += 4;
5297 if cursor != bytes.len() {
5298 return Err(invalid_feed("v2 bulk stream carries trailing data"));
5299 }
5300 Ok(result)
5301}
5302
5303fn download_v2_bulk_stream(
5304 cfg: &HubConfig,
5305 brain: &str,
5306 pointer: &V2PointerBody,
5307 pending: &[(&String, &V2BaselineFile)],
5308) -> LinkResult<Vec<(String, Vec<u8>)>> {
5309 let claims = pending
5310 .iter()
5311 .map(|(path, file)| {
5312 Ok(json!({
5313 "path": path,
5314 "sha256": file.sha256,
5315 "bytes": file.bytes,
5316 "proof": file.proof.as_ref().ok_or_else(|| {
5317 invalid_feed("v2 manifest omitted a bulk-stream proof")
5318 })?,
5319 }))
5320 })
5321 .collect::<LinkResult<Vec<_>>>()?;
5322 let raw = request_raw(
5323 cfg,
5324 "POST",
5325 &format!("/api/hub/brains/{brain}/v2/stream"),
5326 Some(&json!({
5327 "commit": pointer.commit_hash,
5328 "files": claims,
5329 })),
5330 Auth::Required,
5331 V2_BULK_STREAM_RESPONSE_BYTES,
5332 )?;
5333 let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5334 parse_v2_bulk_stream(&body, pending)
5335}
5336
5337fn prepare_v2_downloads(
5338 cfg: &HubConfig,
5339 brain: &str,
5340 pointer: &V2PointerBody,
5341 pending: &[(&String, &V2BaselineFile)],
5342) -> LinkResult<Vec<V2DownloadItem>> {
5343 let mut result = Vec::with_capacity(pending.len());
5344 for chunk in pending.chunks(128) {
5345 let claims = chunk
5346 .iter()
5347 .map(|(path, file)| {
5348 Ok(json!({
5349 "path": path,
5350 "sha256": file.sha256,
5351 "bytes": file.bytes,
5352 "proof": file.proof.as_ref().ok_or_else(|| {
5353 invalid_feed("v2 manifest omitted a download proof")
5354 })?,
5355 }))
5356 })
5357 .collect::<LinkResult<Vec<_>>>()?;
5358 let value = ensure_ok(
5359 request_capped(
5360 cfg,
5361 "POST",
5362 &format!("/api/hub/brains/{brain}/v2/downloads"),
5363 Some(&json!({
5364 "commit": pointer.commit_hash,
5365 "files": claims,
5366 })),
5367 Auth::Required,
5368 MAX_FEED_RESPONSE_BYTES,
5369 )?,
5370 "prepare v2 blob downloads",
5371 )?;
5372 let window: V2DownloadWindow = serde_json::from_value(value)
5373 .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5374 if window.v != 2
5375 || window.commit != pointer.commit_hash
5376 || window.downloads.len() != chunk.len()
5377 {
5378 return Err(invalid_feed(
5379 "v2 download window is not bound to the requested files",
5380 ));
5381 }
5382 let mut by_path = window
5383 .downloads
5384 .into_iter()
5385 .map(|item| (item.path.clone(), item))
5386 .collect::<std::collections::BTreeMap<_, _>>();
5387 if by_path.len() != chunk.len() {
5388 return Err(invalid_feed("v2 download window repeats a path"));
5389 }
5390 for (path, file) in chunk {
5391 let item = by_path
5392 .remove(*path)
5393 .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5394 if item.method != "GET"
5395 || item.sha256 != file.sha256
5396 || item.bytes != file.bytes
5397 || item.url.is_empty()
5398 {
5399 return Err(invalid_feed(
5400 "v2 download capability differs from its proven file",
5401 ));
5402 }
5403 result.push(item);
5404 }
5405 }
5406 Ok(result)
5407}
5408
5409fn prepare_v2_asset_downloads(
5410 cfg: &HubConfig,
5411 brain: &str,
5412 pointer: &V2PointerBody,
5413 pending: &[(&String, &V2BaselineAsset)],
5414) -> LinkResult<Vec<V2DownloadItem>> {
5415 let mut result = Vec::with_capacity(pending.len());
5416 for chunk in pending.chunks(128) {
5417 let claims = chunk
5418 .iter()
5419 .map(|(path, asset)| {
5420 json!({
5421 "path": path,
5422 "sha256": asset.blob_sha256,
5423 "bytes": asset.bytes,
5424 "leaf_hash": asset.leaf_hash,
5425 })
5426 })
5427 .collect::<Vec<_>>();
5428 let value = ensure_ok(
5429 request_capped(
5430 cfg,
5431 "POST",
5432 &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5433 Some(&json!({
5434 "commit": pointer.commit_hash,
5435 "assets": claims,
5436 })),
5437 Auth::Required,
5438 MAX_FEED_RESPONSE_BYTES,
5439 )?,
5440 "prepare v2 asset downloads",
5441 )?;
5442 let window: V2DownloadWindow = serde_json::from_value(value)
5443 .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5444 if window.v != 2
5445 || window.commit != pointer.commit_hash
5446 || window.downloads.len() != chunk.len()
5447 {
5448 return Err(invalid_feed(
5449 "v2 asset download window is not bound to the requested assets",
5450 ));
5451 }
5452 let mut by_path = window
5453 .downloads
5454 .into_iter()
5455 .map(|item| (item.path.clone(), item))
5456 .collect::<std::collections::BTreeMap<_, _>>();
5457 if by_path.len() != chunk.len() {
5458 return Err(invalid_feed("v2 asset download window repeats a path"));
5459 }
5460 for (path, asset) in chunk {
5461 let item = by_path
5462 .remove(*path)
5463 .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5464 if item.method != "GET"
5465 || item.sha256 != asset.blob_sha256
5466 || item.bytes != asset.bytes
5467 || item.url.is_empty()
5468 {
5469 return Err(invalid_feed(
5470 "v2 asset download capability differs from its signed leaf",
5471 ));
5472 }
5473 result.push(item);
5474 }
5475 }
5476 Ok(result)
5477}
5478
5479fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5480 let bytes = get_presigned(cfg, &item.url)?;
5481 if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5482 return Err(invalid_feed("v2 blob differs from its proven path entry"));
5483 }
5484 Ok(bytes)
5485}
5486
5487#[derive(Debug, Clone)]
5488struct V2StagedFile {
5489 path: String,
5490 source: PathBuf,
5491 sha256: String,
5492 bytes: u64,
5493}
5494
5495#[cfg(unix)]
5496fn v2_download_cache_dir(
5497 cfg: &HubConfig,
5498 brain: &str,
5499 pointer: &V2PointerBody,
5500) -> LinkResult<PathBuf> {
5501 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5502}
5503
5504#[cfg(unix)]
5505fn v2_download_cache_dir_for(
5506 cfg: &HubConfig,
5507 brain: &str,
5508 transaction: &str,
5509) -> LinkResult<PathBuf> {
5510 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5511 return Err(invalid_feed("v2 download cache address is invalid"));
5512 }
5513 let path = cfg
5514 .state_dir
5515 .join("downloads")
5516 .join(brain)
5517 .join(transaction);
5518 let directory = open_or_create_dir_nofollow(&path)?;
5519 use std::os::fd::AsRawFd as _;
5520 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5521 return Err(std::io::Error::last_os_error().into());
5522 }
5523 directory.sync_all()?;
5524 Ok(path)
5525}
5526
5527#[cfg(windows)]
5528fn v2_download_cache_dir(
5529 cfg: &HubConfig,
5530 brain: &str,
5531 pointer: &V2PointerBody,
5532) -> LinkResult<PathBuf> {
5533 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5534}
5535
5536#[cfg(windows)]
5537fn v2_download_cache_dir_for(
5538 cfg: &HubConfig,
5539 brain: &str,
5540 transaction: &str,
5541) -> LinkResult<PathBuf> {
5542 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5543 return Err(invalid_feed("v2 download cache address is invalid"));
5544 }
5545 let path = cfg
5546 .state_dir
5547 .join("downloads")
5548 .join(brain)
5549 .join(transaction);
5550 crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5551 crate::fsx::open_directory_nofollow(&path)?;
5552 Ok(path)
5553}
5554
5555#[cfg(unix)]
5556fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5557 use std::os::fd::AsRawFd as _;
5558 let parent = cfg.state_dir.join("downloads").join(brain);
5559 let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5560 return;
5561 };
5562 let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5563 return;
5564 };
5565 let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5566 let _ = directory.sync_all();
5567}
5568
5569#[cfg(windows)]
5570fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5571 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5572 return;
5573 }
5574 let parent = cfg.state_dir.join("downloads").join(brain);
5575 let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5576 return;
5577 };
5578 let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5579}
5580
5581#[cfg(not(any(unix, windows)))]
5582fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5583
5584#[cfg(not(any(unix, windows)))]
5585fn v2_download_cache_dir_for(
5586 _cfg: &HubConfig,
5587 _brain: &str,
5588 _transaction: &str,
5589) -> LinkResult<PathBuf> {
5590 Err(LinkError::UnsupportedPlatform {
5591 operation: "resumable v2 download staging",
5592 })
5593}
5594
5595#[cfg(any(unix, windows))]
5596fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5597 let file = match crate::fsx::open_regular_nofollow(path) {
5598 Ok(file) => file,
5599 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5600 Err(error) => return Err(error.into()),
5601 };
5602 if file.metadata()?.len() != bytes {
5603 return Ok(false);
5604 }
5605 Ok(content_sha256_reader(file)? == sha256)
5606}
5607
5608#[cfg(any(unix, windows))]
5609fn cache_v2_blob_bytes(
5610 cache_dir: &Path,
5611 sha256: &str,
5612 expected_bytes: u64,
5613 bytes: &[u8],
5614) -> LinkResult<PathBuf> {
5615 if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5616 return Err(invalid_feed("v2 cached blob differs from its declaration"));
5617 }
5618 let path = cache_dir.join(sha256);
5619 if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5620 crate::fsx::write_atomic(&path, bytes)?;
5621 }
5622 Ok(path)
5623}
5624
5625#[cfg(not(any(unix, windows)))]
5626fn cache_v2_blob_bytes(
5627 _cache_dir: &Path,
5628 _sha256: &str,
5629 _expected_bytes: u64,
5630 _bytes: &[u8],
5631) -> LinkResult<PathBuf> {
5632 Err(LinkError::UnsupportedPlatform {
5633 operation: "resumable v2 download staging",
5634 })
5635}
5636
5637#[cfg(unix)]
5638fn download_presigned_to_cache(
5639 cfg: &HubConfig,
5640 url: &str,
5641 cache_dir: &Path,
5642 sha256: &str,
5643 expected_bytes: u64,
5644) -> LinkResult<PathBuf> {
5645 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5646
5647 let target = cache_dir.join(sha256);
5648 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5649 return Ok(target);
5650 }
5651 let directory = open_existing_dir_nofollow(cache_dir)?;
5652 let mut nonce = [0_u8; 16];
5653 ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5654 .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5655 let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5656 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5657 let fd = unsafe {
5658 libc::openat(
5659 directory.as_raw_fd(),
5660 temp.as_ptr(),
5661 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5662 0o600,
5663 )
5664 };
5665 if fd < 0 {
5666 return Err(std::io::Error::last_os_error().into());
5667 }
5668 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5669 let response = match presigned_agent(cfg, url)?.get(url).call() {
5670 Ok(response) => response,
5671 Err(ureq::Error::Status(_, response)) => {
5672 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5673 return Err(LinkError::Http {
5674 what: "v2 direct download",
5675 status: response.status(),
5676 message: "object store rejected the download".to_string(),
5677 code: None,
5678 details: None,
5679 });
5680 }
5681 Err(ureq::Error::Transport(error)) => {
5682 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5683 return Err(LinkError::Transport {
5684 hub: cfg.hub.clone(),
5685 message: error.to_string(),
5686 });
5687 }
5688 };
5689 let mut reader = response
5690 .into_reader()
5691 .take(expected_bytes.saturating_add(1));
5692 let mut digest = Sha256::new();
5693 let mut total = 0_u64;
5694 let mut buffer = [0_u8; 64 * 1024];
5695 let write_result = (|| -> LinkResult<()> {
5700 loop {
5701 let read = reader
5702 .read(&mut buffer)
5703 .map_err(|error| LinkError::Transport {
5704 hub: cfg.hub.clone(),
5705 message: error.to_string(),
5706 })?;
5707 if read == 0 {
5708 break;
5709 }
5710 total = total.saturating_add(read as u64);
5711 digest.update(&buffer[..read]);
5712 output.write_all(&buffer[..read])?;
5713 }
5714 output.sync_all().map_err(LinkError::from)
5715 })();
5716 if let Err(error) = write_result {
5717 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5718 return Err(error);
5719 }
5720 drop(output);
5721 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5722 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5723 return Err(invalid_feed(
5724 "v2 direct download failed integrity verification",
5725 ));
5726 }
5727 let target_name = c_name(sha256.as_bytes(), sha256)?;
5728 if unsafe {
5731 libc::renameat(
5732 directory.as_raw_fd(),
5733 temp.as_ptr(),
5734 directory.as_raw_fd(),
5735 target_name.as_ptr(),
5736 )
5737 } != 0
5738 {
5739 let error = std::io::Error::last_os_error();
5740 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5741 return Err(error.into());
5742 }
5743 directory.sync_all()?;
5744 Ok(target)
5745}
5746
5747#[cfg(windows)]
5748fn download_presigned_to_cache(
5749 cfg: &HubConfig,
5750 url: &str,
5751 cache_dir: &Path,
5752 sha256: &str,
5753 expected_bytes: u64,
5754) -> LinkResult<PathBuf> {
5755 use std::fs::OpenOptions;
5756
5757 let target = cache_dir.join(sha256);
5758 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5759 return Ok(target);
5760 }
5761 let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
5765 let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
5766 let mut output = OpenOptions::new()
5767 .write(true)
5768 .create_new(true)
5769 .open(&temp)?;
5770 let response = match presigned_agent(cfg, url)?.get(url).call() {
5771 Ok(response) => response,
5772 Err(ureq::Error::Status(_, response)) => {
5773 let _ = std::fs::remove_file(&temp);
5774 return Err(LinkError::Http {
5775 what: "v2 direct download",
5776 status: response.status(),
5777 message: "object store rejected the download".to_string(),
5778 code: None,
5779 details: None,
5780 });
5781 }
5782 Err(ureq::Error::Transport(error)) => {
5783 let _ = std::fs::remove_file(&temp);
5784 return Err(LinkError::Transport {
5785 hub: cfg.hub.clone(),
5786 message: error.to_string(),
5787 });
5788 }
5789 };
5790 let mut reader = response
5791 .into_reader()
5792 .take(expected_bytes.saturating_add(1));
5793 let mut digest = Sha256::new();
5794 let mut total = 0_u64;
5795 let mut buffer = [0_u8; 64 * 1024];
5796 let copied = (|| -> LinkResult<()> {
5798 loop {
5799 let read = reader
5800 .read(&mut buffer)
5801 .map_err(|error| LinkError::Transport {
5802 hub: cfg.hub.clone(),
5803 message: error.to_string(),
5804 })?;
5805 if read == 0 {
5806 break;
5807 }
5808 total = total.saturating_add(read as u64);
5809 digest.update(&buffer[..read]);
5810 output.write_all(&buffer[..read])?;
5811 }
5812 output.sync_all()?;
5813 Ok(())
5814 })();
5815 if let Err(error) = copied {
5816 let _ = std::fs::remove_file(&temp);
5817 return Err(error.into());
5818 }
5819 drop(output);
5820 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5821 let _ = std::fs::remove_file(&temp);
5822 return Err(invalid_feed(
5823 "v2 direct download failed integrity verification",
5824 ));
5825 }
5826 if target.exists() {
5827 std::fs::remove_file(&target)?;
5828 }
5829 if let Err(error) = std::fs::rename(&temp, &target) {
5830 let _ = std::fs::remove_file(&temp);
5831 return Err(error.into());
5832 }
5833 Ok(target)
5834}
5835
5836#[cfg(not(any(unix, windows)))]
5837fn download_presigned_to_cache(
5838 _cfg: &HubConfig,
5839 _url: &str,
5840 _cache_dir: &Path,
5841 _sha256: &str,
5842 _expected_bytes: u64,
5843) -> LinkResult<PathBuf> {
5844 Err(LinkError::UnsupportedPlatform {
5845 operation: "resumable v2 download staging",
5846 })
5847}
5848
5849fn download_v2_blobs(
5850 cfg: &HubConfig,
5851 brain: &str,
5852 pointer: &V2PointerBody,
5853 pending: Vec<(&String, &V2BaselineFile)>,
5854) -> LinkResult<Vec<(String, Vec<u8>)>> {
5855 if pending.is_empty() {
5856 return Ok(Vec::new());
5857 }
5858 let expected_order = pending
5859 .iter()
5860 .map(|(path, _)| (*path).clone())
5861 .collect::<Vec<_>>();
5862 let mut streamed = std::collections::BTreeMap::new();
5863 let mut direct = Vec::new();
5864 let mut window = Vec::new();
5865 let mut window_bytes = 0_u64;
5866 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5867 window_bytes: &mut u64,
5868 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
5869 -> LinkResult<()> {
5870 if window.is_empty() {
5871 return Ok(());
5872 }
5873 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
5874 if streamed.insert(path, bytes).is_some() {
5875 return Err(invalid_feed("v2 bulk streams repeated a path"));
5876 }
5877 }
5878 window.clear();
5879 *window_bytes = 0;
5880 Ok(())
5881 };
5882 for &(path, file) in &pending {
5883 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5884 flush(&mut window, &mut window_bytes, &mut streamed)?;
5885 direct.push((path, file));
5886 continue;
5887 }
5888 if window.len() == V2_BULK_STREAM_FILES
5889 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5890 {
5891 flush(&mut window, &mut window_bytes, &mut streamed)?;
5892 }
5893 window.push((path, file));
5894 window_bytes += file.bytes;
5895 }
5896 flush(&mut window, &mut window_bytes, &mut streamed)?;
5897
5898 let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
5899 let next = std::sync::atomic::AtomicUsize::new(0);
5900 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5901 let mut results = std::iter::repeat_with(|| None)
5902 .take(downloads.len())
5903 .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
5904 std::thread::scope(|scope| {
5905 let (sender, receiver) = std::sync::mpsc::channel();
5906 for _ in 0..worker_count {
5907 let sender = sender.clone();
5908 let downloads = &downloads;
5909 let next = &next;
5910 scope.spawn(move || loop {
5911 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5912 let Some(item) = downloads.get(index) else {
5913 break;
5914 };
5915 let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
5916 if sender.send((index, result)).is_err() {
5917 break;
5918 }
5919 });
5920 }
5921 drop(sender);
5922 for (index, result) in receiver {
5923 results[index] = Some(result);
5924 }
5925 });
5926 for result in results.into_iter().map(|result| {
5927 result.ok_or_else(|| LinkError::Transport {
5928 hub: cfg.hub.clone(),
5929 message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
5930 })?
5931 }) {
5932 let (path, bytes) = result?;
5933 if streamed.insert(path, bytes).is_some() {
5934 return Err(invalid_feed("v2 download lanes repeated a path"));
5935 }
5936 }
5937 expected_order
5938 .into_iter()
5939 .map(|path| {
5940 streamed
5941 .remove(&path)
5942 .map(|bytes| (path, bytes))
5943 .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
5944 })
5945 .collect()
5946}
5947
5948#[cfg(any(unix, windows))]
5952fn stage_v2_blobs(
5953 cfg: &HubConfig,
5954 brain: &str,
5955 pointer: &V2PointerBody,
5956 pending: Vec<(&String, &V2BaselineFile)>,
5957) -> LinkResult<Vec<V2StagedFile>> {
5958 let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
5959 let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
5960 let mut direct = Vec::new();
5961 let mut window = Vec::new();
5962 let mut window_bytes = 0_u64;
5963 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5964 window_bytes: &mut u64,
5965 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
5966 -> LinkResult<()> {
5967 if window.is_empty() {
5968 return Ok(());
5969 }
5970 let missing = window
5971 .iter()
5972 .filter_map(|(path, file)| {
5973 let target = cache_dir.join(&file.sha256);
5974 match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
5975 Ok(true) => {
5976 staged.insert(
5977 (*path).clone(),
5978 V2StagedFile {
5979 path: (*path).clone(),
5980 source: target,
5981 sha256: file.sha256.clone(),
5982 bytes: file.bytes,
5983 },
5984 );
5985 None
5986 }
5987 Ok(false) => Some(Ok((*path, *file))),
5988 Err(error) => Some(Err(error)),
5989 }
5990 })
5991 .collect::<LinkResult<Vec<_>>>()?;
5992 if !missing.is_empty() {
5993 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
5994 let file = missing
5995 .iter()
5996 .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
5997 .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
5998 let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
5999 staged.insert(
6000 path.clone(),
6001 V2StagedFile {
6002 path,
6003 source,
6004 sha256: file.sha256.clone(),
6005 bytes: file.bytes,
6006 },
6007 );
6008 }
6009 }
6010 window.clear();
6011 *window_bytes = 0;
6012 Ok(())
6013 };
6014 for &(path, file) in &pending {
6015 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6016 flush(&mut window, &mut window_bytes, &mut staged)?;
6017 direct.push((path, file));
6018 continue;
6019 }
6020 if window.len() == V2_BULK_STREAM_FILES
6021 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6022 {
6023 flush(&mut window, &mut window_bytes, &mut staged)?;
6024 }
6025 window.push((path, file));
6026 window_bytes += file.bytes;
6027 }
6028 flush(&mut window, &mut window_bytes, &mut staged)?;
6029 for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6030 let source =
6031 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6032 staged.insert(
6033 item.path.clone(),
6034 V2StagedFile {
6035 path: item.path,
6036 source,
6037 sha256: item.sha256,
6038 bytes: item.bytes,
6039 },
6040 );
6041 }
6042 pending
6043 .into_iter()
6044 .map(|(path, _)| {
6045 staged
6046 .remove(path)
6047 .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6048 })
6049 .collect()
6050}
6051
6052#[cfg(not(any(unix, windows)))]
6053fn stage_v2_blobs(
6054 _cfg: &HubConfig,
6055 _brain: &str,
6056 _pointer: &V2PointerBody,
6057 _pending: Vec<(&String, &V2BaselineFile)>,
6058) -> LinkResult<Vec<V2StagedFile>> {
6059 Err(LinkError::UnsupportedPlatform {
6060 operation: "resumable v2 download staging",
6061 })
6062}
6063
6064const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6065const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6066const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6067
6068#[derive(Debug, Clone, Deserialize, Serialize)]
6069struct V2ConflictCoordinate {
6070 sha256: Option<String>,
6071 bytes: Option<u64>,
6072 file: Option<String>,
6073}
6074
6075#[derive(Debug, Clone, Deserialize, Serialize)]
6076struct V2ConflictFile {
6077 path: String,
6078 base: V2ConflictCoordinate,
6079 local: V2ConflictCoordinate,
6080 remote: V2ConflictCoordinate,
6081}
6082
6083#[derive(Debug, Clone, Deserialize, Serialize)]
6084struct V2ConflictPlan {
6085 v: u8,
6086 class: String,
6087 bundle: String,
6088 brain: String,
6089 origin: String,
6090 created_unix: u64,
6091 expires_unix: u64,
6092 base_seq: Option<u64>,
6093 base_commit: Option<String>,
6094 remote_seq: u64,
6095 remote_commit: Option<String>,
6096 remote_content_root: Option<String>,
6097 view_kind: String,
6098 view_revision: String,
6099 files: Vec<V2ConflictFile>,
6100}
6101
6102fn v2_take_remote_selection(
6103 files: &[V2ConflictFile],
6104 current: &std::collections::BTreeMap<String, V2BaselineFile>,
6105) -> LinkResult<(
6106 std::collections::BTreeMap<String, V2BaselineFile>,
6107 Vec<String>,
6108)> {
6109 let mut selected = std::collections::BTreeMap::new();
6110 let mut deleted = Vec::new();
6111 for file in files {
6112 match (&file.remote.sha256, file.remote.bytes) {
6113 (Some(sha256), Some(bytes)) => {
6114 let proven = current.get(&file.path).ok_or_else(|| {
6115 invalid_feed("conflict remote coordinate disappeared from the exact head")
6116 })?;
6117 if proven.sha256 != *sha256 || proven.bytes != bytes {
6118 return Err(invalid_feed(
6119 "conflict remote coordinate differs from the exact head",
6120 ));
6121 }
6122 if selected.insert(file.path.clone(), proven.clone()).is_some() {
6123 return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6124 }
6125 }
6126 (None, None) => {
6127 if current.contains_key(&file.path) {
6128 return Err(invalid_feed(
6129 "conflict remote deletion differs from the exact head",
6130 ));
6131 }
6132 deleted.push(file.path.clone());
6133 }
6134 _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6135 }
6136 }
6137 Ok((selected, deleted))
6138}
6139
6140fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6141 PathBuf::from(".dbmd")
6142 .join("conflicts")
6143 .join(bundle)
6144 .join(suffix)
6145}
6146
6147fn read_historical_conflict_blob(
6148 cfg: &HubConfig,
6149 brain: &str,
6150 baseline: &V2SyncBaseline,
6151 path: &str,
6152 file: &V2BaselineFile,
6153) -> LinkResult<Option<Vec<u8>>> {
6154 let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6155 return Ok(None);
6156 };
6157 if seq == 0 {
6158 return Ok(None);
6159 }
6160 let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6161 let endpoint = format!(
6162 "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6163 file.sha256
6164 );
6165 let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
6166 if raw.status == 404 || raw.status == 403 {
6167 return Ok(None);
6168 }
6169 let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6170 if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6171 return Err(invalid_feed(
6172 "v2 conflict base failed integrity verification",
6173 ));
6174 }
6175 Ok(Some(bytes))
6176}
6177
6178fn create_v2_conflict_bundle(
6181 cfg: &HubConfig,
6182 store: &Store,
6183 head: &V2VerifiedHead,
6184 baseline: Option<&V2SyncBaseline>,
6185 local: &std::collections::BTreeMap<String, (String, u64)>,
6186 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6187 paths: &[String],
6188) -> LinkResult<(String, Vec<String>)> {
6189 let conflicts_root = Path::new(".dbmd/conflicts");
6190 store.create_dir_all(conflicts_root)?;
6191 let completed = store
6192 .directory_names(conflicts_root)?
6193 .into_iter()
6194 .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6195 .count();
6196 if completed >= V2_CONFLICT_BUNDLE_MAX {
6197 return Err(LinkError::InvalidPack {
6198 message: format!(
6199 "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6200 ),
6201 });
6202 }
6203
6204 let mut selected_paths = Vec::new();
6208 let mut selected_remote_bytes = 0_u64;
6209 for path in paths {
6210 let bytes = remote.get(path).map_or(0, |file| file.bytes);
6211 if !selected_paths.is_empty()
6212 && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6213 {
6214 break;
6215 }
6216 selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6217 selected_paths.push(path.clone());
6218 if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6219 break;
6220 }
6221 }
6222 if selected_paths.is_empty() {
6223 return Err(invalid_feed("content conflict set is empty"));
6224 }
6225 let bundle = crate::ulid::mint();
6226 let bundle_root = v2_conflict_relative(&bundle, "");
6227 store.create_dir_all(&bundle_root.join("files"))?;
6228 let pointer = head.pointer.as_ref();
6229 let remote_bytes = match pointer {
6230 Some(pointer) => download_v2_blobs(
6231 cfg,
6232 &head.brain_id,
6233 pointer,
6234 selected_paths
6235 .iter()
6236 .filter_map(|path| {
6237 remote
6238 .get(path)
6239 .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6240 .map(|file| (path, file))
6241 })
6242 .collect(),
6243 )?
6244 .into_iter()
6245 .collect::<std::collections::BTreeMap<_, _>>(),
6246 None => std::collections::BTreeMap::new(),
6247 };
6248
6249 let mut files = Vec::with_capacity(selected_paths.len());
6250 for (index, path) in selected_paths.iter().enumerate() {
6251 let base_file = baseline.and_then(|state| state.files.get(path));
6252 let base_bytes = match (baseline, base_file) {
6253 (Some(state), Some(file)) => {
6254 read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
6255 }
6256 _ => None,
6257 };
6258 let local_file = local.get(path);
6259 let remote_file = remote.get(path);
6260 let remote_content = remote_bytes.get(path);
6261 let prefix = format!("files/{index:04}");
6262 let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6263 let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6264 let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6265 if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6266 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6267 }
6268 if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6269 let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6270 if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6271 return Err(LinkError::InvalidPack {
6272 message: format!("local conflict path `{path}` changed while bundling"),
6273 });
6274 }
6275 store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6276 }
6277 if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6278 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6279 }
6280 files.push(V2ConflictFile {
6281 path: path.clone(),
6282 base: V2ConflictCoordinate {
6283 sha256: base_file.map(|file| file.sha256.clone()),
6284 bytes: base_file.map(|file| file.bytes),
6285 file: base_name,
6286 },
6287 local: V2ConflictCoordinate {
6288 sha256: local_file.map(|(sha256, _)| sha256.clone()),
6289 bytes: local_file.map(|(_, bytes)| *bytes),
6290 file: local_name,
6291 },
6292 remote: V2ConflictCoordinate {
6293 sha256: remote_file.map(|file| file.sha256.clone()),
6294 bytes: remote_file.map(|file| file.bytes),
6295 file: remote_name,
6296 },
6297 });
6298 }
6299 let now = SystemTime::now()
6300 .duration_since(UNIX_EPOCH)
6301 .unwrap_or_default()
6302 .as_secs();
6303 let plan = V2ConflictPlan {
6304 v: 2,
6305 class: "content_resolution_required".to_string(),
6306 bundle: bundle.clone(),
6307 brain: head.brain_id.clone(),
6308 origin: normalized_origin(&cfg.hub)?,
6309 created_unix: now,
6310 expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6311 base_seq: baseline.and_then(|state| state.head_seq),
6312 base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6313 remote_seq: pointer.map_or(0, |value| value.seq),
6314 remote_commit: pointer.map(|value| value.commit_hash.clone()),
6315 remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6316 view_kind: head.view_kind.clone(),
6317 view_revision: head.view_revision.clone(),
6318 files,
6319 };
6320 let mut bytes = serde_json::to_vec_pretty(&plan)
6321 .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6322 bytes.push(b'\n');
6323 store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6324 Ok((bundle, selected_paths))
6325}
6326
6327fn v2_sync_pull_with_resolution(
6328 cfg: &HubConfig,
6329 requested_brain: &str,
6330 expected_head: V2VerifiedHead,
6331 out: Option<&Path>,
6332 take_remote: Option<&std::collections::BTreeSet<String>>,
6333) -> LinkResult<V2PulledSnapshot> {
6334 let dest = out
6335 .map(Path::to_path_buf)
6336 .unwrap_or_else(|| PathBuf::from(requested_brain));
6337 let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6338 recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6339 let head = v2_verified_head(cfg, requested_brain)?
6340 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6341 if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6342 return Err(LinkError::RemoteAdvancedDuringSync);
6343 }
6344 let remote = files_for_v2_view(
6345 &head,
6346 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6347 );
6348 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
6349 let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6350 ensure_v2_view_compatible(&head, baseline.as_ref())?;
6351 let local_store = Store::open_strict(&dest).ok();
6352 ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6357 let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6358 if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6359 return Err(LinkError::ScopedViewChanged);
6360 }
6361 if let Some(view) = local_view.as_mut() {
6362 remove_scoped_projection(&head, baseline.as_ref(), view)?;
6363 }
6364 let empty_local = std::collections::BTreeMap::new();
6365 let local = local_view
6366 .as_ref()
6367 .map_or(&empty_local, |view| &view.riding);
6368 let kept_home = |path: &str| {
6369 local_view
6370 .as_ref()
6371 .is_some_and(|view| view.policy.keeps_home(path))
6372 };
6373 let empty_base = std::collections::BTreeMap::new();
6374 let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6375 let empty_base_assets = std::collections::BTreeMap::new();
6376 let base_assets = baseline
6377 .as_ref()
6378 .map_or(&empty_base_assets, |state| &state.assets);
6379 let mut local_assets = local_store
6380 .as_ref()
6381 .map(v2_local_asset_records)
6382 .transpose()?
6383 .unwrap_or_default();
6384 let mut content_merge = merge_v2_pulled_records(
6385 base,
6386 &remote,
6387 local,
6388 |file, _| (file.sha256.clone(), file.bytes),
6389 |file, _| (file.sha256.clone(), file.bytes),
6390 kept_home,
6391 );
6392 if let Some(selected) = take_remote {
6393 for path in selected {
6394 if let Some(position) = content_merge
6395 .conflicts
6396 .iter()
6397 .position(|conflict| conflict == path)
6398 {
6399 content_merge.conflicts.remove(position);
6400 content_merge.accept_remote.insert(path.clone());
6401 match remote.get(path) {
6402 Some(file) => {
6403 content_merge
6404 .records
6405 .insert(path.clone(), (file.sha256.clone(), file.bytes));
6406 }
6407 None => {
6408 content_merge.records.remove(path);
6409 }
6410 }
6411 } else if !content_merge.accept_remote.contains(path) {
6412 return Err(LinkError::InvalidPack {
6413 message: format!(
6414 "take-remote path `{path}` is no longer at its conflict coordinate"
6415 ),
6416 });
6417 }
6418 }
6419 }
6420 if !content_merge.conflicts.is_empty() {
6421 let mut conflicts = content_merge.conflicts.clone();
6422 conflicts.truncate(100);
6423 if let Some(store) = local_store.as_ref() {
6424 let (bundle, paths) = create_v2_conflict_bundle(
6425 cfg,
6426 store,
6427 &head,
6428 baseline.as_ref(),
6429 local,
6430 &remote,
6431 &conflicts,
6432 )?;
6433 return Err(LinkError::ConflictBundle { bundle, paths });
6434 }
6435 return Err(LinkError::Conflict { paths: conflicts });
6436 }
6437 let asset_merge = merge_v2_pulled_records(
6438 base_assets,
6439 &remote_assets,
6440 &local_assets,
6441 v2_asset_record,
6442 v2_asset_record,
6443 |_| false,
6444 );
6445 if !asset_merge.conflicts.is_empty() {
6446 let mut conflicts = asset_merge.conflicts.clone();
6447 conflicts.truncate(100);
6448 return Err(LinkError::Conflict { paths: conflicts });
6449 }
6450 let pointer = head.pointer.as_ref();
6451 let cache_transaction = pointer.map_or_else(
6452 || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6453 |value| value.commit_hash.clone(),
6454 );
6455 let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6456 let mut changed = match pointer {
6457 Some(pointer) => stage_v2_blobs(
6458 cfg,
6459 &head.brain_id,
6460 pointer,
6461 remote
6462 .iter()
6463 .filter(|(path, file)| {
6464 content_merge.accept_remote.contains(*path)
6465 && local.get(*path).map(|value| value.0.as_str())
6466 != Some(file.sha256.as_str())
6467 })
6468 .collect(),
6469 )?,
6470 None => Vec::new(),
6471 };
6472 let mut deleted = content_merge
6473 .accept_remote
6474 .iter()
6475 .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6476 .cloned()
6477 .collect::<Vec<_>>();
6478 if local_assets != asset_merge.records {
6479 if asset_merge.records.is_empty() {
6480 deleted.push("assets.jsonl".to_string());
6481 } else {
6482 let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6483 let sha256 = content_sha256(&bytes);
6484 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6485 changed.push(V2StagedFile {
6486 path: "assets.jsonl".to_string(),
6487 source,
6488 sha256,
6489 bytes: bytes.len() as u64,
6490 });
6491 }
6492 }
6493 if let Some(pointer) = pointer {
6494 let mut pending_assets = Vec::new();
6495 for (path, asset) in &remote_assets {
6496 if asset.disposition != "hosted"
6497 || kept_home(path)
6498 || !asset_merge.accept_remote.contains(path)
6499 {
6500 continue;
6501 }
6502 let already_current = local_store.as_ref().is_some_and(|store| {
6503 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6504 && store
6505 .read_bounded(Path::new(path), asset.bytes)
6506 .ok()
6507 .is_some_and(|bytes| {
6508 bytes.len() as u64 == asset.bytes
6509 && content_sha256(&bytes) == asset.blob_sha256
6510 })
6511 });
6512 if !already_current {
6513 pending_assets.push((path, asset));
6514 }
6515 }
6516 for item in prepare_v2_asset_downloads(cfg, &head.brain_id, pointer, &pending_assets)? {
6517 let source =
6518 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6519 changed.push(V2StagedFile {
6520 path: item.path,
6521 source,
6522 sha256: item.sha256,
6523 bytes: item.bytes,
6524 });
6525 }
6526 }
6527 for (path, prior) in base_assets {
6528 if remote_assets.contains_key(path)
6529 || kept_home(path)
6530 || !asset_merge.accept_remote.contains(path)
6531 {
6532 continue;
6533 }
6534 let unchanged = local_store.as_ref().is_some_and(|store| {
6535 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6536 && store
6537 .read_bounded(Path::new(path), prior.bytes)
6538 .ok()
6539 .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6540 });
6541 if unchanged {
6542 deleted.push(path.clone());
6543 }
6544 }
6545 let extra_local = content_merge
6546 .records
6547 .keys()
6548 .filter(|path| !remote.contains_key(*path))
6549 .cloned()
6550 .collect::<Vec<_>>();
6551 if head.view_kind == "scoped" {
6552 for (path, bytes) in [
6553 ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6554 (
6555 ".dbmd/view.json".to_string(),
6556 scoped_view_metadata(&head, remote.len())?,
6557 ),
6558 ] {
6559 let sha256 = content_sha256(&bytes);
6560 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6561 changed.push(V2StagedFile {
6562 path,
6563 source,
6564 sha256,
6565 bytes: bytes.len() as u64,
6566 });
6567 }
6568 }
6569 let install_changed = !changed.is_empty() || !deleted.is_empty();
6570 install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6571 let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6572 let installed_store =
6573 Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6574 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6575 })?;
6576 let installed_local = if install_changed {
6577 let mut scanned = v2_local_files(&installed_store)?;
6578 remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6579 scanned
6580 } else {
6581 local_view
6582 .take()
6583 .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6584 };
6585 if installed_local.riding != content_merge.records {
6586 return Err(LinkError::InvalidPack {
6587 message: "local content changed while installing the v2 pull".to_string(),
6588 });
6589 }
6590 let installed_assets = if install_changed {
6591 v2_local_asset_records(&installed_store)?
6592 } else {
6593 std::mem::take(&mut local_assets)
6594 };
6595 if installed_assets != asset_merge.records {
6596 return Err(LinkError::InvalidPack {
6597 message: "local assets changed while installing the v2 pull".to_string(),
6598 });
6599 }
6600 let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6601 installed_local.policy.keeps_home(path)
6602 })
6603 || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6604 let final_head = v2_verified_head(cfg, requested_brain)?
6605 .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6606 if !same_v2_head(&head, &final_head) {
6607 return Err(LinkError::RemoteAdvancedDuringSync);
6608 }
6609 accept_v2_head(cfg, &final_head)?;
6610 save_v2_baseline(
6611 cfg,
6612 &head.brain_id,
6613 &dest,
6614 &v2_baseline_from_head(
6615 cfg,
6616 &head,
6617 remote.clone(),
6618 remote_assets.clone(),
6619 Some(&installed_local),
6620 baseline
6621 .as_ref()
6622 .and_then(|current| current.checkout_id.as_deref()),
6623 )?,
6624 )?;
6625 complete_v2_pull(&dest)?;
6626 Ok((local_dirty, installed_local, installed_assets))
6627 })();
6628 let (local_dirty, installed_local, installed_assets) = match finalized {
6629 Ok(value) => value,
6630 Err(error) => {
6631 if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6632 return Err(LinkError::InvalidPack {
6633 message: format!("{error}; durable pull recovery also failed: {recovery}"),
6634 });
6635 }
6636 return Err(error);
6637 }
6638 };
6639 cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6640 let report = PullReport {
6641 brain: head.brain_id.clone(),
6642 slug: requested_brain.to_string(),
6643 head_seq: pointer.map_or(0, |value| value.seq),
6644 files: remote.len() + remote_assets.len(),
6645 dest: dest.to_string_lossy().into_owned(),
6646 extra_local,
6647 sync_status: if local_dirty {
6648 "local_dirty_after_install".to_string()
6649 } else {
6650 "synced".to_string()
6651 },
6652 };
6653 Ok(V2PulledSnapshot {
6654 report,
6655 head,
6656 files: remote,
6657 assets: remote_assets,
6658 local: installed_local,
6659 local_assets: installed_assets,
6660 })
6661}
6662
6663fn v2_sync_pull(
6664 cfg: &HubConfig,
6665 requested_brain: &str,
6666 head: V2VerifiedHead,
6667 out: Option<&Path>,
6668) -> LinkResult<PullReport> {
6669 Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
6670}
6671
6672fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
6673 match remote {
6674 Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
6675 None => json!({ "kind": "absent" }),
6676 }
6677}
6678
6679fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
6680 match remote {
6681 Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
6682 None => json!({ "kind": "absent" }),
6683 }
6684}
6685
6686fn v2_content_withdrawal_operation(
6687 store: &Store,
6688 local_view: &V2LocalView,
6689 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6690 path: &str,
6691 reason: &str,
6692) -> LinkResult<Value> {
6693 if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
6694 || path == "DB.md"
6695 {
6696 return Err(LinkError::InvalidPack {
6697 message: format!("content withdrawal path `{path}` is not a record or source"),
6698 });
6699 }
6700 if !local_view.policy.keeps_home(path)
6701 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6702 {
6703 return Err(LinkError::InvalidPack {
6704 message: format!(
6705 "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6706 ),
6707 });
6708 }
6709 let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
6710 message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
6711 })?;
6712 verify_v2_upload_source(store, path, ¤t.sha256, current.bytes)?;
6713 Ok(json!({
6714 "op": "withdraw_from_hosting",
6715 "path": path,
6716 "expected": { "kind": "blob", "hash": current.sha256 },
6717 "reason": reason,
6718 }))
6719}
6720
6721fn v2_asset_withdrawal_operation(
6722 store: &Store,
6723 local_view: &V2LocalView,
6724 path: &str,
6725 local: &crate::AssetRecord,
6726 current: &V2BaselineAsset,
6727 reason: &str,
6728) -> LinkResult<Value> {
6729 if !local_view.policy.keeps_home(path)
6730 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6731 {
6732 return Err(LinkError::InvalidPack {
6733 message: format!(
6734 "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6735 ),
6736 });
6737 }
6738 verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
6739 if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
6740 return Err(LinkError::InvalidPack {
6741 message: format!(
6742 "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
6743 ),
6744 });
6745 }
6746 Ok(json!({
6747 "op": "asset_withdraw",
6748 "path": path,
6749 "expected": v2_asset_expected(Some(current)),
6750 "reason": reason,
6751 }))
6752}
6753
6754fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
6761 let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
6762 let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
6763 for (index, operation) in operations.iter().enumerate() {
6764 match operation.get("op").and_then(Value::as_str) {
6765 Some("delete") => {
6766 let Some(path) = operation.get("path").and_then(Value::as_str) else {
6767 continue;
6768 };
6769 let Some(hash) = operation
6770 .get("expected")
6771 .and_then(|value| value.get("hash"))
6772 .and_then(Value::as_str)
6773 else {
6774 continue;
6775 };
6776 if path.starts_with("sources/") {
6777 deletes
6778 .entry(hash.to_string())
6779 .or_default()
6780 .push((index, path.to_string()));
6781 }
6782 }
6783 Some("put") => {
6784 let Some(path) = operation.get("path").and_then(Value::as_str) else {
6785 continue;
6786 };
6787 let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
6788 continue;
6789 };
6790 let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
6791 continue;
6792 };
6793 let destination_absent = operation
6794 .get("expected")
6795 .and_then(|value| value.get("kind"))
6796 .and_then(Value::as_str)
6797 == Some("absent");
6798 if path.starts_with("sources/") && destination_absent {
6799 puts.entry(hash.to_string()).or_default().push((
6800 index,
6801 path.to_string(),
6802 bytes,
6803 ));
6804 }
6805 }
6806 _ => {}
6807 }
6808 }
6809 let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
6810 let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
6811 for (hash, source) in deletes {
6812 let Some(destination) = puts.get(&hash) else {
6813 continue;
6814 };
6815 if source.len() != 1 || destination.len() != 1 {
6816 continue;
6817 }
6818 let (delete_index, from) = &source[0];
6819 let (put_index, to, bytes) = &destination[0];
6820 if from == to {
6821 continue;
6822 }
6823 rename_at.insert(
6824 *delete_index,
6825 json!({
6826 "op": "rename",
6827 "from": from,
6828 "to": to,
6829 "expected_from": { "kind": "blob", "hash": hash },
6830 "expected_to": { "kind": "absent" },
6831 "blob": hash,
6832 "bytes": bytes,
6833 }),
6834 );
6835 consumed_puts.insert(*put_index);
6836 }
6837 operations
6838 .into_iter()
6839 .enumerate()
6840 .filter_map(|(index, operation)| {
6841 if let Some(rename) = rename_at.remove(&index) {
6842 Some(rename)
6843 } else if consumed_puts.contains(&index) {
6844 None
6845 } else {
6846 Some(operation)
6847 }
6848 })
6849 .collect()
6850}
6851
6852fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
6853 json!({
6854 "blob_sha256": record.sha256,
6855 "bytes": record.bytes,
6856 "media_type": record.media_type,
6857 "wrappers": record.wrappers,
6858 "required": record.required,
6859 "disposition": disposition,
6860 })
6861}
6862
6863fn apply_generated_v2_operations(
6867 operations: &[Value],
6868 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
6869 candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
6870 candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
6871) -> LinkResult<bool> {
6872 let mut asset_changed = false;
6873 for operation in operations {
6874 match operation.get("op").and_then(Value::as_str) {
6875 Some("put") => {
6876 let path = operation
6877 .get("path")
6878 .and_then(Value::as_str)
6879 .ok_or_else(|| invalid_feed("v2 put has no path"))?;
6880 let sha256 = operation
6881 .get("blob")
6882 .and_then(Value::as_str)
6883 .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
6884 let bytes = operation
6885 .get("bytes")
6886 .and_then(Value::as_u64)
6887 .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
6888 candidate.insert(
6889 path.to_string(),
6890 V2BaselineFile {
6891 sha256: sha256.to_string(),
6892 bytes,
6893 proof: None,
6894 },
6895 );
6896 }
6897 Some("rename") => {
6898 let from = operation
6899 .get("from")
6900 .and_then(Value::as_str)
6901 .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
6902 let to = operation
6903 .get("to")
6904 .and_then(Value::as_str)
6905 .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
6906 let sha256 = operation
6907 .get("blob")
6908 .and_then(Value::as_str)
6909 .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
6910 let bytes = operation
6911 .get("bytes")
6912 .and_then(Value::as_u64)
6913 .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
6914 let expected_from = operation
6915 .get("expected_from")
6916 .and_then(|expected| expected.get("hash"))
6917 .and_then(Value::as_str);
6918 let expected_to_absent = operation
6919 .get("expected_to")
6920 .and_then(|expected| expected.get("kind"))
6921 .and_then(Value::as_str)
6922 == Some("absent");
6923 if from == to
6924 || !from.starts_with("sources/")
6925 || !to.starts_with("sources/")
6926 || expected_from != Some(sha256)
6927 || !expected_to_absent
6928 || candidate.contains_key(to)
6929 {
6930 return Err(invalid_feed("generated v2 source rename is malformed"));
6931 }
6932 let source = candidate
6933 .remove(from)
6934 .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
6935 if source.sha256 != sha256 || source.bytes != bytes {
6936 return Err(invalid_feed(
6937 "v2 rename source differs from its exact-byte claim",
6938 ));
6939 }
6940 candidate.insert(
6941 to.to_string(),
6942 V2BaselineFile {
6943 sha256: sha256.to_string(),
6944 bytes,
6945 proof: None,
6946 },
6947 );
6948 }
6949 Some("delete" | "withdraw_from_hosting") => {
6950 let path = operation
6951 .get("path")
6952 .and_then(Value::as_str)
6953 .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
6954 candidate.remove(path);
6955 }
6956 Some("asset_delete") => {
6957 let path = operation
6958 .get("path")
6959 .and_then(Value::as_str)
6960 .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
6961 candidate_assets.remove(path);
6962 asset_changed = true;
6963 }
6964 Some("asset_put" | "asset_resume" | "asset_withdraw") => {
6965 let path = operation
6966 .get("path")
6967 .and_then(Value::as_str)
6968 .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
6969 let record = local_assets
6970 .get(path)
6971 .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
6972 let disposition =
6973 if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
6974 "withheld"
6975 } else {
6976 operation
6977 .get("asset")
6978 .and_then(|asset| asset.get("disposition"))
6979 .and_then(Value::as_str)
6980 .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
6981 };
6982 candidate_assets.insert(
6983 path.to_string(),
6984 V2BaselineAsset {
6985 blob_sha256: record.sha256.clone(),
6986 bytes: record.bytes,
6987 media_type: record.media_type.clone(),
6988 wrappers: record.wrappers.clone(),
6989 required: record.required,
6990 disposition: disposition.to_string(),
6991 leaf_hash: String::new(),
6994 },
6995 );
6996 asset_changed = true;
6997 }
6998 _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
6999 }
7000 }
7001 Ok(asset_changed)
7002}
7003
7004fn v2_riding_matches_remote(
7005 local: &std::collections::BTreeMap<String, (String, u64)>,
7006 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7007 keeps_home: impl Fn(&str) -> bool,
7008) -> bool {
7009 remote.iter().all(|(path, file)| {
7010 keeps_home(path)
7011 || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7012 }) && local.iter().all(|(path, (hash, _))| {
7013 remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7014 })
7015}
7016
7017#[derive(Debug, Clone)]
7018struct V2ResolutionOverride {
7019 expected_remote: Option<String>,
7020 selected_local: Option<String>,
7021}
7022
7023#[derive(Debug, Clone)]
7024struct V2UploadSource {
7025 path: String,
7026 bytes: u64,
7027}
7028
7029struct V2SyncPushOptions<'a> {
7030 resume_local_policy: bool,
7031 bulk_confirmation: Option<&'a V2BulkConfirmation>,
7032 resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7033 pulled: Option<V2PulledSnapshot>,
7034 withdrawal_paths: &'a [String],
7035 withdrawal_reason: Option<&'a str>,
7036}
7037
7038fn verify_v2_upload_source(
7039 store: &Store,
7040 path: &str,
7041 sha256: &str,
7042 expected_bytes: u64,
7043) -> LinkResult<()> {
7044 let file = store.open_regular(Path::new(path))?;
7045 if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7046 return Err(LinkError::InvalidPack {
7047 message: format!("local path `{path}` changed during sync planning"),
7048 });
7049 }
7050 Ok(())
7051}
7052
7053struct V2PendingUpload<'a> {
7056 url: String,
7057 headers: Value,
7058 sha256: String,
7059 source: &'a V2UploadSource,
7060}
7061
7062const V2_UPLOAD_CONCURRENCY: usize = 16;
7069
7070fn upload_v2_batch_concurrently(
7074 cfg: &HubConfig,
7075 store: &Store,
7076 pending: &[V2PendingUpload<'_>],
7077) -> LinkResult<()> {
7078 if pending.is_empty() {
7079 return Ok(());
7080 }
7081 let urls = pending
7082 .iter()
7083 .map(|task| task.url.as_str())
7084 .collect::<Vec<_>>();
7085 let shared = shared_staging_agent(cfg, &urls);
7086 if pending.len() == 1 {
7087 let task = &pending[0];
7088 put_presigned_source(
7089 cfg,
7090 &task.url,
7091 &task.headers,
7092 store,
7093 task.source,
7094 shared.as_ref(),
7095 )?;
7096 return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7097 }
7098 let next = std::sync::atomic::AtomicUsize::new(0);
7099 let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7100 let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7101 std::thread::scope(|scope| {
7102 for _ in 0..workers {
7103 scope.spawn(|| loop {
7104 if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7105 return;
7106 }
7107 let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7108 let Some(task) = pending.get(index) else {
7109 return;
7110 };
7111 let outcome = put_presigned_source(
7112 cfg,
7113 &task.url,
7114 &task.headers,
7115 store,
7116 task.source,
7117 shared.as_ref(),
7118 )
7119 .and_then(|()| {
7120 verify_v2_upload_source(
7121 store,
7122 &task.source.path,
7123 &task.sha256,
7124 task.source.bytes,
7125 )
7126 });
7127 if let Err(error) = outcome {
7128 if let Ok(mut guard) = failure.lock() {
7129 guard.get_or_insert(error);
7130 }
7131 return;
7132 }
7133 });
7134 }
7135 });
7136 match failure.into_inner() {
7137 Ok(Some(error)) => Err(error),
7138 Ok(None) => Ok(()),
7139 Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7140 }
7141}
7142
7143fn put_presigned_source(
7144 cfg: &HubConfig,
7145 raw: &str,
7146 headers: &Value,
7147 store: &Store,
7148 source: &V2UploadSource,
7149 shared: Option<&ureq::Agent>,
7150) -> LinkResult<()> {
7151 let owned = match shared {
7154 Some(_) => {
7155 checked_presigned_url(cfg, raw)?;
7156 None
7157 }
7158 None => Some(presigned_agent(cfg, raw)?),
7159 };
7160 let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7161 let mut attempt = 0;
7162 let result = loop {
7163 let file = store.open_regular(Path::new(&source.path))?;
7164 if file.metadata()?.len() != source.bytes {
7165 return Err(LinkError::InvalidPack {
7166 message: format!("local path `{}` changed before upload", source.path),
7167 });
7168 }
7169 let mut req = http.put(raw);
7174 let mut has_content_length = false;
7175 if let Some(map) = headers.as_object() {
7176 for (name, value) in map {
7177 if let Some(value) = value.as_str() {
7178 has_content_length |= name.eq_ignore_ascii_case("content-length");
7179 req = req.set(name, value);
7180 }
7181 }
7182 }
7183 if !has_content_length {
7184 req = req.set("Content-Length", &source.bytes.to_string());
7185 }
7186 match req.send(file) {
7187 Err(ureq::Error::Transport(_)) if attempt + 1 < UPLOAD_ATTEMPTS => {
7193 std::thread::sleep(std::time::Duration::from_millis(upload_retry_backoff_ms(
7194 attempt,
7195 )));
7196 attempt += 1;
7197 }
7198 Err(ureq::Error::Status(status, _))
7204 if status != 412
7205 && is_retryable_upload_status(status)
7206 && attempt + 1 < UPLOAD_ATTEMPTS =>
7207 {
7208 std::thread::sleep(std::time::Duration::from_millis(upload_retry_backoff_ms(
7209 attempt,
7210 )));
7211 attempt += 1;
7212 }
7213 result => break result,
7214 }
7215 };
7216 match result {
7217 Ok(response) if (200..300).contains(&response.status()) => {
7218 drain_presigned_response(response);
7219 Ok(())
7220 }
7221 Ok(response) => {
7222 let status = response.status();
7227 let detail = response
7228 .into_string()
7229 .ok()
7230 .map(|body| body.chars().take(400).collect::<String>())
7231 .filter(|body| !body.trim().is_empty());
7232 Err(LinkError::Http {
7233 what: "v2 changed-byte upload",
7234 status,
7235 message: match detail {
7236 Some(body) => format!(
7237 "object store rejected the upload of `{}`: {}",
7238 source.path,
7239 body.replace('\n', " ")
7240 ),
7241 None => format!("object store rejected the upload of `{}`", source.path),
7242 },
7243 code: None,
7244 details: None,
7245 })
7246 }
7247 Err(error) => match error {
7248 ureq::Error::Status(412, _) => Ok(()),
7249 ureq::Error::Status(_, response) => {
7250 let status = response.status();
7251 let detail = response
7252 .into_string()
7253 .ok()
7254 .map(|body| body.chars().take(400).collect::<String>())
7255 .filter(|body| !body.trim().is_empty());
7256 Err(LinkError::Http {
7257 what: "v2 changed-byte upload",
7258 status,
7259 message: match detail {
7260 Some(body) => format!(
7261 "object store rejected the upload of `{}`: {}",
7262 source.path,
7263 body.replace('\n', " ")
7264 ),
7265 None => {
7266 format!("object store rejected the upload of `{}`", source.path)
7267 }
7268 },
7269 code: None,
7270 details: None,
7271 })
7272 }
7273 ureq::Error::Transport(error) => Err(LinkError::Transport {
7274 hub: "the object store".to_string(),
7275 message: error.to_string(),
7276 }),
7277 },
7278 }
7279}
7280
7281fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
7285 if body.get("operations").is_some() {
7286 return body.clone();
7287 }
7288 let mut value = body.clone();
7289 if let Some(map) = value.as_object_mut() {
7290 map.remove("staged_change");
7291 map.insert("operations".to_string(), Value::Array(operations.to_vec()));
7292 }
7293 value
7294}
7295
7296fn reserve_upload_window(
7300 cfg: &HubConfig,
7301 path: &str,
7302 body: &Value,
7303 what: &'static str,
7304) -> LinkResult<Value> {
7305 let mut attempt = 0;
7306 loop {
7307 let pause = |attempt: usize| {
7308 std::thread::sleep(std::time::Duration::from_millis(
7309 RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
7310 ));
7311 };
7312 match request(cfg, "POST", path, Some(body), Auth::Required) {
7313 Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
7318 pause(attempt);
7319 attempt += 1;
7320 }
7321 Err(error) => return Err(error),
7322 Ok(response) => {
7323 if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
7324 pause(attempt);
7325 attempt += 1;
7326 continue;
7327 }
7328 return ensure_ok(response, what);
7329 }
7330 }
7331 }
7332}
7333
7334fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
7338 let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
7339 .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
7340 if bytes.len() > MAX_STAGED_CHANGE_BYTES {
7341 return Err(LinkError::PushTooLarge {
7342 detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
7343 });
7344 }
7345 Ok(bytes)
7346}
7347
7348fn stage_v2_change(
7358 cfg: &HubConfig,
7359 requested_brain: &str,
7360 operations: &[Value],
7361 blobs: Value,
7362) -> LinkResult<Value> {
7363 let bytes = v2_change_manifest(operations, blobs)?;
7364 let sha256 = content_sha256(&bytes);
7365 let reserved = reserve_upload_window(
7366 cfg,
7367 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7368 &json!({
7369 "blobs": [{
7370 "sha256": sha256,
7371 "bytes": bytes.len(),
7372 "kind": "staged_change",
7373 }],
7374 }),
7375 "stage the v2 change",
7376 )?;
7377 let items = reserved
7378 .get("uploads")
7379 .and_then(Value::as_array)
7380 .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
7381 let [item] = items.as_slice() else {
7382 return Err(invalid_feed(
7383 "v2 change staging response changed the requested set",
7384 ));
7385 };
7386 let reservation_id = item
7387 .get("reservation_id")
7388 .and_then(Value::as_str)
7389 .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
7390 if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
7391 || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
7392 || !crate::ulid::is_ulid(reservation_id)
7393 {
7394 return Err(invalid_feed("v2 change staging item is inconsistent"));
7395 }
7396 match item.get("status").and_then(Value::as_str) {
7397 Some("upload") => put_presigned(
7398 cfg,
7399 item.get("url")
7400 .and_then(Value::as_str)
7401 .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
7402 item.get("headers").unwrap_or(&Value::Null),
7403 &bytes,
7404 )?,
7405 Some("already_present") => {}
7406 _ => return Err(invalid_feed("v2 change staging has an unknown status")),
7407 }
7408 Ok(json!({
7409 "sha256": sha256,
7410 "bytes": bytes.len(),
7411 "reservation_id": reservation_id,
7412 }))
7413}
7414
7415fn stage_oversized_v2_change(
7419 cfg: &HubConfig,
7420 requested_brain: &str,
7421 operations: &[Value],
7422 body: &mut Value,
7423) -> LinkResult<()> {
7424 if body.to_string().len() <= MAX_PUSH_BYTES {
7425 return Ok(());
7426 }
7427 let staged = stage_v2_change(
7428 cfg,
7429 requested_brain,
7430 operations,
7431 body.get("blobs")
7432 .cloned()
7433 .unwrap_or(Value::Array(Vec::new())),
7434 )?;
7435 let map = body
7436 .as_object_mut()
7437 .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
7438 map.remove("operations");
7439 map.remove("blobs");
7440 map.insert("staged_change".to_string(), staged);
7441 Ok(())
7442}
7443
7444fn v2_sync_push(
7445 cfg: &HubConfig,
7446 requested_brain: &str,
7447 store: &Store,
7448 head: V2VerifiedHead,
7449 options: V2SyncPushOptions<'_>,
7450) -> LinkResult<Value> {
7451 let V2SyncPushOptions {
7452 resume_local_policy,
7453 bulk_confirmation,
7454 resolution,
7455 pulled,
7456 withdrawal_paths,
7457 withdrawal_reason,
7458 } = options;
7459 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
7460 let head = v2_verified_head(cfg, requested_brain)?
7461 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7462 let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
7463 match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
7464 Some(snapshot) => (
7465 snapshot.files,
7466 snapshot.assets,
7467 Some(snapshot.local),
7468 Some(snapshot.local_assets),
7469 ),
7470 None => (
7471 files_for_v2_view(
7472 &head,
7473 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7474 ),
7475 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7476 None,
7477 None,
7478 ),
7479 };
7480 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
7481 ensure_v2_view_compatible(&head, baseline.as_ref())?;
7482 if head.view_kind == "scoped" && baseline.is_none() {
7483 return Err(LinkError::ScopedViewChanged);
7484 }
7485 let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
7486 let local = &local_view.riding;
7487 let local_assets = match carried_local_assets {
7488 Some(assets) => assets,
7489 None => v2_local_asset_records(store)?,
7490 };
7491 if withdrawal_paths.len() > MAX_PUSH_FILES {
7492 return Err(LinkError::PushTooLarge {
7493 detail: "too many explicit withdrawal paths".to_string(),
7494 });
7495 }
7496 let withdrawal_reason = if withdrawal_paths.is_empty() {
7497 None
7498 } else {
7499 let reason = withdrawal_reason
7500 .map(str::trim)
7501 .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
7502 .ok_or_else(|| LinkError::InvalidPack {
7503 message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
7504 })?;
7505 Some(reason)
7506 };
7507 let mut withdrawals = withdrawal_paths
7508 .iter()
7509 .map(|path| {
7510 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
7511 path: error.to_string(),
7512 })
7513 })
7514 .collect::<LinkResult<Vec<_>>>()?;
7515 withdrawals.sort();
7516 withdrawals.dedup();
7517 if withdrawals.len() != withdrawal_paths.len() {
7518 return Err(LinkError::InvalidPack {
7519 message: "explicit withdrawal paths must be unique".to_string(),
7520 });
7521 }
7522 let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
7523 let mut consumed_withdrawals = BTreeSet::new();
7524 if let Some(previous) = baseline.as_ref() {
7525 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
7526 && !resume_local_policy
7527 {
7528 let mut newly_eligible = previous
7529 .local_eligibility
7530 .iter()
7531 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7532 .map(|(path, _)| path.clone())
7533 .collect::<Vec<_>>();
7534 if !newly_eligible.is_empty() {
7535 newly_eligible.truncate(100);
7536 return Err(LinkError::LocalPolicyTransition {
7537 paths: newly_eligible,
7538 });
7539 }
7540 }
7541 }
7542 let base = match baseline.as_ref() {
7543 Some(state) => &state.files,
7544 None if remote.is_empty() => &remote,
7545 None => {
7546 let mut conflicts = remote
7547 .iter()
7548 .filter(|(path, file)| {
7549 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7550 })
7551 .map(|(path, _)| path.clone())
7552 .collect::<Vec<_>>();
7553 if !conflicts.is_empty() {
7554 conflicts.truncate(100);
7555 let (bundle, paths) =
7556 create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7557 return Err(LinkError::ConflictBundle { bundle, paths });
7558 }
7559 &remote
7560 }
7561 };
7562 let all_paths = base
7563 .keys()
7564 .chain(remote.keys())
7565 .chain(local.keys())
7566 .cloned()
7567 .collect::<std::collections::BTreeSet<_>>();
7568 let mut conflicts = Vec::new();
7569 let mut operations = Vec::new();
7570 let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7571 for path in all_paths {
7572 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7573 let remote_file = remote.get(&path);
7574 let remote_hash = remote_file.map(|file| file.sha256.as_str());
7575 let local_file = local.get(&path);
7576 let local_hash = local_file.map(|file| file.0.as_str());
7577 if local_hash == base_hash {
7578 continue;
7579 }
7580 if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
7581 continue;
7582 }
7583 if local_view.policy.keeps_home(&path) {
7584 continue;
7587 }
7588 if remote_hash != base_hash && local_hash != remote_hash {
7589 let explicitly_resolved = resolution
7590 .and_then(|allowed| allowed.get(&path))
7591 .is_some_and(|selected| {
7592 selected.expected_remote.as_deref() == remote_hash
7593 && selected.selected_local.as_deref() == local_hash
7594 });
7595 if !explicitly_resolved {
7596 conflicts.push(path);
7597 continue;
7598 }
7599 }
7600 match local_file {
7601 Some((sha256, byte_count)) => {
7602 verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7603 operations.push(json!({
7604 "op": "put",
7605 "path": path,
7606 "expected": v2_expected(remote_file),
7607 "blob": sha256,
7608 "bytes": byte_count,
7609 }));
7610 upload_sources
7611 .entry(sha256.clone())
7612 .or_insert_with(|| V2UploadSource {
7613 path: path.clone(),
7614 bytes: *byte_count,
7615 });
7616 }
7617 None => {
7618 let Some(current) = remote_file else {
7619 continue;
7620 };
7621 operations.push(json!({
7622 "op": "delete",
7623 "path": path,
7624 "expected": { "kind": "blob", "hash": current.sha256 },
7625 }));
7626 }
7627 }
7628 }
7629 operations = infer_exact_source_promotions(operations);
7630 for path in &withdrawals {
7631 if local_assets.contains_key(path) {
7632 continue;
7633 }
7634 operations.push(v2_content_withdrawal_operation(
7635 store,
7636 &local_view,
7637 &remote,
7638 path,
7639 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7640 )?);
7641 consumed_withdrawals.insert(path.clone());
7642 }
7643 if !conflicts.is_empty() {
7644 conflicts.truncate(100);
7645 let (bundle, paths) = create_v2_conflict_bundle(
7646 cfg,
7647 store,
7648 &head,
7649 baseline.as_ref(),
7650 local,
7651 &remote,
7652 &conflicts,
7653 )?;
7654 return Err(LinkError::ConflictBundle { bundle, paths });
7655 }
7656 let base_assets = match baseline.as_ref() {
7657 Some(state) => &state.assets,
7658 None if remote_assets.is_empty() => &remote_assets,
7659 None => {
7660 let mismatched = remote_assets.iter().any(|(path, remote)| {
7661 local_assets.get(path) != Some(&v2_asset_record(remote, path))
7662 }) || local_assets.len() != remote_assets.len();
7663 if mismatched {
7664 return Err(LinkError::Conflict {
7665 paths: vec!["assets.jsonl".to_string()],
7666 });
7667 }
7668 &remote_assets
7669 }
7670 };
7671 let asset_paths = base_assets
7672 .keys()
7673 .chain(remote_assets.keys())
7674 .chain(local_assets.keys())
7675 .cloned()
7676 .collect::<std::collections::BTreeSet<_>>();
7677 let mut asset_policy_transitions = Vec::new();
7678 for path in asset_paths {
7679 let base_record = base_assets
7680 .get(&path)
7681 .map(|asset| v2_asset_record(asset, &path));
7682 let remote = remote_assets.get(&path);
7683 let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
7684 let local_record = local_assets.get(&path);
7685 if withdrawal_set.contains(&path) {
7686 let record = local_record.ok_or_else(|| LinkError::InvalidPack {
7687 message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
7688 })?;
7689 let current = remote.ok_or_else(|| LinkError::InvalidPack {
7690 message: format!(
7691 "asset withdrawal path `{path}` has no readable hosted coordinate"
7692 ),
7693 })?;
7694 operations.push(v2_asset_withdrawal_operation(
7695 store,
7696 &local_view,
7697 &path,
7698 record,
7699 current,
7700 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7701 )?);
7702 consumed_withdrawals.insert(path.clone());
7703 continue;
7704 }
7705 let mut raw_present = false;
7706 let mut disposition = "withheld";
7707 let mut resumes_hosting = false;
7708 if let Some(record) = local_record {
7709 crate::linkmd_v2::normalize_path(&record.path)
7710 .map_err(|error| invalid_feed(error.to_string()))?;
7711 let kept_home = local_view.policy.keeps_home(&path);
7712 raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
7713 disposition = if kept_home || !raw_present {
7714 "withheld"
7715 } else {
7716 "hosted"
7717 };
7718 if !raw_present && record.required && !kept_home {
7719 return Err(LinkError::InvalidPack {
7720 message: format!("required asset {path} is missing"),
7721 });
7722 }
7723 resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
7724 }
7725 if local_record == base_record.as_ref() && !resumes_hosting {
7726 continue;
7727 }
7728 if remote_record != base_record && local_record != remote_record.as_ref() {
7729 conflicts.push(path);
7730 continue;
7731 }
7732 let Some(record) = local_record else {
7733 if let Some(remote) = remote {
7734 operations.push(json!({
7735 "op": "asset_delete",
7736 "path": path,
7737 "expected": v2_asset_expected(Some(remote)),
7738 }));
7739 }
7740 continue;
7741 };
7742 let raw = if raw_present {
7743 verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
7744 Some(())
7745 } else {
7746 None
7747 };
7748 let op = if resumes_hosting {
7749 if !resume_local_policy {
7750 asset_policy_transitions.push(path);
7751 continue;
7752 }
7753 "asset_resume"
7754 } else {
7755 "asset_put"
7756 };
7757 operations.push(json!({
7758 "op": op,
7759 "path": path,
7760 "expected": v2_asset_expected(remote),
7761 "asset": v2_asset_value(record, disposition),
7762 }));
7763 if disposition == "hosted" {
7764 raw.expect("hosted asset was checked present");
7765 upload_sources
7766 .entry(record.sha256.clone())
7767 .or_insert_with(|| V2UploadSource {
7768 path: path.clone(),
7769 bytes: record.bytes,
7770 });
7771 }
7772 }
7773 if consumed_withdrawals != withdrawal_set {
7774 let missing = withdrawal_set
7775 .difference(&consumed_withdrawals)
7776 .next()
7777 .expect("different withdrawal sets have one member");
7778 return Err(LinkError::InvalidPack {
7779 message: format!(
7780 "withdrawal path `{missing}` is not a readable content or asset coordinate"
7781 ),
7782 });
7783 }
7784 if !conflicts.is_empty() {
7785 conflicts.truncate(100);
7786 return Err(LinkError::Conflict { paths: conflicts });
7787 }
7788 if !asset_policy_transitions.is_empty() {
7789 asset_policy_transitions.truncate(100);
7790 return Err(LinkError::LocalPolicyTransition {
7791 paths: asset_policy_transitions,
7792 });
7793 }
7794 let touched_sources = operations
7795 .iter()
7796 .filter_map(
7797 |operation| match operation.get("op").and_then(Value::as_str) {
7798 Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
7799 Some("rename") => operation.get("to").and_then(Value::as_str),
7800 _ => None,
7801 },
7802 )
7803 .collect::<std::collections::BTreeSet<_>>();
7804 let withheld_links = local_view
7805 .withheld_links
7806 .iter()
7807 .filter(|link| touched_sources.contains(link.source.as_str()))
7808 .collect::<Vec<_>>();
7809 let checkout_pseudonym = v2_checkout_id(
7810 baseline
7811 .as_ref()
7812 .and_then(|current| current.checkout_id.as_deref()),
7813 )?;
7814 let checkout_id = if withheld_links.is_empty() {
7815 None
7816 } else {
7817 Some(checkout_pseudonym.clone())
7818 };
7819 if operations.is_empty() {
7820 let final_head = v2_verified_head(cfg, requested_brain)?
7821 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
7822 if !same_v2_head(&head, &final_head) {
7823 return Err(LinkError::RemoteAdvancedDuringSync);
7824 }
7825 let mut final_local = v2_local_files(store)?;
7826 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
7827 let final_assets = v2_local_asset_records(store)?;
7828 let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
7829 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
7830 final_local.policy.keeps_home(path)
7831 }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
7832 let next = v2_baseline_from_head(
7833 cfg,
7834 &head,
7835 remote,
7836 remote_assets,
7837 Some(&final_local),
7838 Some(&checkout_pseudonym),
7839 )?;
7840 let split_count = next.remote_copy_remains.len();
7841 accept_v2_head(cfg, &final_head)?;
7842 if !local_changed && !remote_ahead {
7843 refresh_scoped_view_marker(store, &head, next.files.len())?;
7844 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
7845 }
7846 return Ok(json!({
7847 "v": 2,
7848 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
7849 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
7850 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
7851 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
7852 "local_policy": {
7853 "remote_copy_remains": split_count,
7854 },
7855 }));
7856 }
7857 let includes_contract = operations
7858 .iter()
7859 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
7860 let rebase = if head.pointer.is_none() || includes_contract {
7861 "strict"
7862 } else {
7863 "disjoint"
7864 };
7865 let base_value = head.pointer.as_ref().map(|pointer| {
7866 json!({
7867 "seq": pointer.seq,
7868 "commit_hash": pointer.commit_hash,
7869 "content_root": pointer.content_root,
7870 "asset_root": pointer.asset_root,
7871 })
7872 });
7873 let entropy = format!(
7877 "{}\0{}\0{}\0{}\0{}\0{}",
7878 normalized_origin(&cfg.hub)?,
7879 head.brain_id,
7880 serde_json::to_string(&base_value).unwrap_or_default(),
7881 serde_json::to_string(&operations).unwrap_or_default(),
7882 serde_json::to_string(&withheld_links).unwrap_or_default(),
7883 checkout_id.as_deref().unwrap_or("")
7884 );
7885 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
7886 let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
7887 total
7888 .checked_add(source.bytes)
7889 .ok_or_else(|| LinkError::PushTooLarge {
7890 detail: "v2 changed-byte total overflow".to_string(),
7891 })
7892 })?;
7893 let inline = changed_bytes <= 3 * 1024 * 1024;
7894 let inline_blobs = if inline {
7895 upload_sources
7896 .iter()
7897 .map(|(sha256, source)| {
7898 let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
7899 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
7900 return Err(LinkError::InvalidPack {
7901 message: format!("local path `{}` changed before upload", source.path),
7902 });
7903 }
7904 Ok(json!({
7905 "sha256": sha256,
7906 "bytes": source.bytes,
7907 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
7908 }))
7909 })
7910 .collect::<LinkResult<Vec<_>>>()?
7911 } else {
7912 Vec::new()
7913 };
7914 let mut body = json!({
7915 "mutation_id": mutation_id,
7916 "base": base_value,
7917 "rebase": rebase,
7918 "reason": "dbmd sync",
7919 "operations": operations,
7920 "blobs": inline_blobs,
7921 });
7922 if !withheld_links.is_empty() {
7923 body["withheld_links"] = serde_json::to_value(&withheld_links)
7924 .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
7925 body["checkout_id"] =
7926 Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
7927 }
7928 if let Some(confirmation) = bulk_confirmation {
7929 if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
7930 return Err(LinkError::InvalidPack {
7931 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
7932 .to_string(),
7933 });
7934 }
7935 body["rebase"] = Value::String("strict".to_string());
7939 body["bulk_preview_id"] = Value::String(confirmation.id.clone());
7940 body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
7941 }
7942 if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
7943 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
7944 for operation in &operations {
7945 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
7946 return Err(invalid_feed("v2 upload operation has no kind"));
7947 };
7948 let hash = match kind {
7949 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
7950 "asset_put" | "asset_resume" => operation
7951 .get("asset")
7952 .and_then(|asset| asset.get("blob_sha256"))
7953 .and_then(Value::as_str),
7954 _ => None,
7955 };
7956 let Some(hash) = hash else { continue };
7957 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
7958 if kind == "rename" {
7959 for field in ["from", "to"] {
7960 coordinates.insert(
7961 operation
7962 .get(field)
7963 .and_then(Value::as_str)
7964 .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
7965 .to_string(),
7966 );
7967 }
7968 } else {
7969 let path = operation
7970 .get("path")
7971 .and_then(Value::as_str)
7972 .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
7973 coordinates.insert(if kind.starts_with("asset_") {
7974 format!("assets/{path}")
7975 } else {
7976 path.to_string()
7977 });
7978 }
7979 }
7980 let declarations = upload_sources
7981 .iter()
7982 .map(|(sha256, source)| {
7983 json!({
7984 "sha256": sha256,
7985 "bytes": source.bytes,
7986 "coordinates": coordinates_by_hash
7987 .get(sha256)
7988 .into_iter()
7989 .flatten()
7990 .collect::<Vec<_>>(),
7991 })
7992 })
7993 .collect::<Vec<_>>();
7994 let mut references = Vec::with_capacity(upload_sources.len());
7995 let mut seen = std::collections::BTreeSet::new();
7996 let mut reserved_count = 0usize;
7997 let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
7998 for batch in batch_upload_declarations(declarations) {
8002 let batch_len = batch.len();
8003 let reserved = reserve_upload_window(
8004 cfg,
8005 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8006 &json!({ "blobs": batch }),
8007 "prepare v2 changed-byte uploads",
8008 )?;
8009 let items = reserved
8010 .get("uploads")
8011 .and_then(Value::as_array)
8012 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8013 if items.len() != batch_len {
8014 return Err(invalid_feed(
8015 "v2 upload reservation response changed the requested set",
8016 ));
8017 }
8018 reserved_count += items.len();
8019 for item in items {
8020 let sha256 = item
8021 .get("sha256")
8022 .and_then(Value::as_str)
8023 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8024 let source = upload_sources
8025 .get(sha256)
8026 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8027 let declared_bytes = item
8028 .get("bytes")
8029 .and_then(Value::as_u64)
8030 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8031 let reservation_id = item
8032 .get("reservation_id")
8033 .and_then(Value::as_str)
8034 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8035 let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8036 invalid_feed("v2 upload reservation has no coordinate binding")
8037 })?;
8038 let returned_coordinates = item
8039 .get("coordinates")
8040 .and_then(Value::as_array)
8041 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8042 if declared_bytes != source.bytes
8043 || !crate::ulid::is_ulid(reservation_id)
8044 || !seen.insert(sha256.to_string())
8045 || returned_coordinates.len() != expected_coordinates.len()
8046 || returned_coordinates
8047 .iter()
8048 .zip(expected_coordinates)
8049 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8050 {
8051 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8052 }
8053 match item.get("status").and_then(Value::as_str) {
8054 Some("upload") => {
8055 let url = item
8056 .get("url")
8057 .and_then(Value::as_str)
8058 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8059 pending_uploads.push(V2PendingUpload {
8060 url: url.to_string(),
8061 headers: item.get("headers").cloned().unwrap_or(Value::Null),
8062 sha256: sha256.to_string(),
8063 source,
8064 });
8065 }
8066 Some("already_present") => {}
8067 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
8068 }
8069 references.push(json!({
8070 "sha256": sha256,
8071 "bytes": source.bytes,
8072 "reservation_id": reservation_id,
8073 }));
8074 }
8075 upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
8081 pending_uploads.clear();
8082 }
8083 if reserved_count != upload_sources.len() {
8084 return Err(invalid_feed(
8085 "v2 upload reservation response changed the requested set",
8086 ));
8087 }
8088 body["blobs"] = Value::Array(references);
8089 }
8090 stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
8091 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
8092 let mut candidate_hub_signer: Option<String> = None;
8093 let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8094 let bulk_preview_required = !(200..300).contains(&response.status)
8095 && response.body.as_ref().is_some_and(|value| {
8096 value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
8097 || value
8098 .get("details")
8099 .and_then(|details| details.get("code"))
8100 .and_then(Value::as_str)
8101 == Some("bulk_preview_required")
8102 });
8103 if bulk_preview_required && bulk_confirmation.is_none() {
8104 body["rebase"] = Value::String("strict".to_string());
8105 body["preview_only"] = Value::Bool(true);
8106 let preview = ensure_ok(
8107 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
8108 "v2 bulk preview",
8109 )?;
8110 let preview_code = preview.get("code").and_then(Value::as_str);
8111 let required = preview.get("required").and_then(Value::as_bool);
8112 if preview.get("v").and_then(Value::as_u64) != Some(2)
8113 || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
8114 || !matches!(
8115 preview_code,
8116 Some("bulk_preview_created" | "bulk_preview_not_required")
8117 )
8118 || required.is_none()
8119 {
8120 return Err(invalid_feed(
8121 "bulk preview response is not bound to the requested mutation",
8122 ));
8123 }
8124 if required == Some(true) {
8125 let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
8126 let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
8127 if preview_code != Some("bulk_preview_created")
8128 || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
8129 || preview_digest.is_none_or(|value| !is_sha256(value))
8130 || preview.get("expires_at").and_then(Value::as_str).is_none()
8131 || !preview.get("impact").is_some_and(Value::is_object)
8132 {
8133 return Err(invalid_feed("bulk preview receipt is malformed"));
8134 }
8135 return Err(LinkError::BulkPreviewRequired { preview });
8136 }
8137 if preview_code != Some("bulk_preview_not_required") {
8138 return Err(invalid_feed("bulk preview requirement is inconsistent"));
8139 }
8140 body.as_object_mut()
8143 .expect("v2 commit request is an object")
8144 .remove("preview_only");
8145 response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8146 }
8147 let mut result = ensure_ok(response, "v2 sync push")?;
8148 if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
8149 if let Some(object) = result.as_object_mut() {
8150 object.insert(
8151 "sync_status".to_string(),
8152 Value::String("proposal_pending".to_string()),
8153 );
8154 }
8155 return Ok(result);
8156 }
8157 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
8158 let request_id = result
8159 .get("request_id")
8160 .and_then(Value::as_str)
8161 .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
8162 .to_string();
8163 let challenge = result
8164 .get("signing_challenge")
8165 .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
8166 let mut expected_candidate = remote.clone();
8167 let mut expected_candidate_assets = remote_assets.clone();
8168 apply_generated_v2_operations(
8169 &operations,
8170 &local_assets,
8171 &mut expected_candidate,
8172 &mut expected_candidate_assets,
8173 )?;
8174 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
8175 cfg,
8176 &head,
8177 &expected_candidate,
8178 &expected_candidate_assets,
8179 &mutation_id,
8180 &v2_signed_request_view(&body, &operations),
8181 challenge,
8182 )?;
8183 body["signing_challenge_id"] = Value::String(challenge_id);
8184 body["signature_base64url"] = Value::String(signature);
8185 candidate_hub_signer = Some(actor_signer);
8186 result = ensure_ok(
8187 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
8188 "v2 self-custody commit",
8189 )?;
8190 }
8191 let refreshed = v2_verified_head(cfg, requested_brain)?
8192 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
8193 if candidate_hub_signer
8194 .as_ref()
8195 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
8196 {
8197 return Err(invalid_feed(
8198 "self-custody actor signer differs from the committed hub pointer signer",
8199 ));
8200 }
8201 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
8202 if refreshed
8203 .pointer
8204 .as_ref()
8205 .map(|pointer| pointer.commit_hash.as_str())
8206 != accepted_hash
8207 {
8208 return Err(LinkError::RemoteAdvancedDuringSync);
8209 }
8210 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
8211 let rebased = result
8212 .get("rebased")
8213 .and_then(Value::as_bool)
8214 .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
8215 let (refreshed_files, refreshed_assets) = if rebased {
8216 (
8217 files_for_v2_view(
8218 &refreshed,
8219 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8220 ),
8221 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8222 )
8223 } else {
8224 let asset_changed = apply_generated_v2_operations(
8225 &operations,
8226 &local_assets,
8227 &mut remote,
8228 &mut remote_assets,
8229 )?;
8230 let assets = if asset_changed {
8231 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
8234 } else {
8235 remote_assets
8236 };
8237 (remote, assets)
8238 };
8239 let mut final_local = v2_local_files(store)?;
8240 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
8241 let final_assets = v2_local_asset_records(store)?;
8242 let local_dirty = final_local.riding != local_view.riding
8243 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
8244 final_local.policy.keeps_home(path)
8245 })
8246 || final_assets != local_assets
8247 || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
8248 let next = v2_baseline_from_head(
8249 cfg,
8250 &refreshed,
8251 refreshed_files,
8252 refreshed_assets,
8253 Some(&final_local),
8254 Some(&checkout_pseudonym),
8255 )?;
8256 let split_count = next.remote_copy_remains.len();
8257 accept_v2_head(cfg, &refreshed)?;
8258 if !local_dirty {
8259 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
8260 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
8261 }
8262 if let Some(object) = result.as_object_mut() {
8263 object.insert(
8264 "local_policy".to_string(),
8265 json!({ "remote_copy_remains": split_count }),
8266 );
8267 object.insert(
8268 "sync_status".to_string(),
8269 Value::String(if local_dirty {
8270 "remote_committed_local_dirty".to_string()
8271 } else {
8272 "synced".to_string()
8273 }),
8274 );
8275 }
8276 Ok(result)
8277}
8278
8279pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
8282 sync_push_incremental_with_policy(cfg, brain, store, false)
8283}
8284
8285pub fn sync_push_incremental_with_policy(
8288 cfg: &HubConfig,
8289 brain: &str,
8290 store: &Store,
8291 resume_local_policy: bool,
8292) -> LinkResult<Value> {
8293 sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
8294}
8295
8296pub fn sync_push_incremental_with_options(
8299 cfg: &HubConfig,
8300 brain: &str,
8301 store: &Store,
8302 resume_local_policy: bool,
8303 bulk_confirmation: Option<&V2BulkConfirmation>,
8304) -> LinkResult<Value> {
8305 sync_push_incremental_with_controls(
8306 cfg,
8307 brain,
8308 store,
8309 resume_local_policy,
8310 bulk_confirmation,
8311 &[],
8312 None,
8313 )
8314}
8315
8316pub fn sync_push_incremental_with_controls(
8318 cfg: &HubConfig,
8319 brain: &str,
8320 store: &Store,
8321 resume_local_policy: bool,
8322 bulk_confirmation: Option<&V2BulkConfirmation>,
8323 withdrawal_paths: &[String],
8324 withdrawal_reason: Option<&str>,
8325) -> LinkResult<Value> {
8326 require_safe_ref(brain)?;
8327 if let Some(head) = v2_verified_head(cfg, brain)? {
8328 return v2_sync_push(
8329 cfg,
8330 brain,
8331 store,
8332 head,
8333 V2SyncPushOptions {
8334 resume_local_policy,
8335 bulk_confirmation,
8336 resolution: None,
8337 pulled: None,
8338 withdrawal_paths,
8339 withdrawal_reason,
8340 },
8341 );
8342 }
8343 if !withdrawal_paths.is_empty() {
8344 return Err(LinkError::InvalidPack {
8345 message: "explicit withdrawal requires a link.md v2 brain".to_string(),
8346 });
8347 }
8348 legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
8349}
8350
8351pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
8355 require_safe_ref(brain)?;
8356 Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
8357}
8358
8359#[cfg(windows)]
8360fn legacy_sync_push_incremental(
8361 _cfg: &HubConfig,
8362 _brain: &str,
8363 _store: &Store,
8364 _resume_local_policy: bool,
8365 _bulk_confirmation: Option<&V2BulkConfirmation>,
8366) -> LinkResult<Value> {
8367 Err(LinkError::UnsupportedPlatform {
8368 operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
8369 })
8370}
8371
8372#[cfg(not(windows))]
8373fn legacy_sync_push_incremental(
8374 cfg: &HubConfig,
8375 brain: &str,
8376 store: &Store,
8377 resume_local_policy: bool,
8378 bulk_confirmation: Option<&V2BulkConfirmation>,
8379) -> LinkResult<Value> {
8380 if resume_local_policy || bulk_confirmation.is_some() {
8381 return Err(LinkError::InvalidPack {
8382 message: "v2 sync options require a link.md v2 brain".to_string(),
8383 });
8384 }
8385 let files = collect_push_files(store)?;
8386 sync_push(cfg, brain, &files)
8387}
8388
8389#[derive(Debug, Clone)]
8391pub enum V2ConflictChoice {
8392 KeepLocal,
8393 TakeRemote,
8394 From(PathBuf),
8395}
8396
8397fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
8398 if !crate::ulid::is_ulid(bundle) {
8399 return Err(LinkError::InvalidPack {
8400 message: "conflict bundle must be a lowercase ULID".to_string(),
8401 });
8402 }
8403 let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
8404 let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
8405 .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
8406 if plan.v != 2
8407 || plan.class != "content_resolution_required"
8408 || plan.bundle != bundle
8409 || !crate::ulid::is_ulid(&plan.brain)
8410 || plan.files.is_empty()
8411 || plan.files.len() > 100
8412 || plan.files.iter().any(|file| {
8413 crate::linkmd_v2::normalize_path(&file.path).is_err()
8414 || [&file.base, &file.local, &file.remote]
8415 .into_iter()
8416 .any(|coordinate| {
8417 coordinate
8418 .sha256
8419 .as_deref()
8420 .is_some_and(|hash| !is_sha256(hash))
8421 || coordinate.file.as_deref().is_some_and(|name| {
8422 name.starts_with('/')
8423 || name
8424 .split('/')
8425 .any(|part| part.is_empty() || part == "." || part == "..")
8426 })
8427 })
8428 })
8429 {
8430 return Err(invalid_feed("private conflict plan failed validation"));
8431 }
8432 Ok(plan)
8433}
8434
8435pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
8440 require_hardened_filesystem("private conflict maintenance")?;
8441 if all && !prune {
8442 return Err(LinkError::InvalidPack {
8443 message: "discarding all conflict bundles requires prune=true".to_string(),
8444 });
8445 }
8446 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8447 message: format!("conflict checkout is not a valid db.md store: {error}"),
8448 })?;
8449 let _transaction = store.transaction()?;
8450 let root = Path::new(".dbmd/conflicts");
8451 let names = match store.directory_names(root) {
8452 Ok(names) => names,
8453 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8454 Err(error) => return Err(error.into()),
8455 };
8456 let now = SystemTime::now()
8457 .duration_since(UNIX_EPOCH)
8458 .unwrap_or_default()
8459 .as_secs();
8460 let mut bundles = Vec::new();
8461 let mut pruned = 0_u64;
8462 for name in names {
8463 let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
8464 continue;
8465 };
8466 let plan_path = v2_conflict_relative(bundle, "plan.json");
8467 let plan_exists = store.regular_file_exists(&plan_path)?;
8468 let expired = if plan_exists {
8469 match load_v2_conflict_plan(&store, bundle) {
8470 Ok(plan) => plan.expires_unix < now,
8471 Err(error) if all => {
8472 let _ = error;
8473 true
8474 }
8475 Err(error) => return Err(error),
8476 }
8477 } else {
8478 true
8479 };
8480 if prune && (all || expired) {
8481 store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8482 pruned += 1;
8483 continue;
8484 }
8485 bundles.push(json!({
8486 "bundle": bundle,
8487 "complete": plan_exists,
8488 "expired": expired,
8489 }));
8490 }
8491 Ok(json!({
8492 "v": 2,
8493 "class": "private_conflict_state",
8494 "bundles": bundles.len(),
8495 "pruned": pruned,
8496 "items": bundles,
8497 }))
8498}
8499
8500pub fn sync_resolve_conflict(
8504 cfg: &HubConfig,
8505 checkout: &Path,
8506 bundle: &str,
8507 choice: V2ConflictChoice,
8508 bulk_confirmation: Option<&V2BulkConfirmation>,
8509) -> LinkResult<Value> {
8510 require_hardened_filesystem("conflict resolution")?;
8511 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8512 message: format!("conflict checkout is not a valid db.md store: {error}"),
8513 })?;
8514 let plan = load_v2_conflict_plan(&store, bundle)?;
8515 if plan.origin != normalized_origin(&cfg.hub)? {
8516 return Err(invalid_feed(
8517 "conflict bundle belongs to another hub origin",
8518 ));
8519 }
8520 let now = SystemTime::now()
8521 .duration_since(UNIX_EPOCH)
8522 .unwrap_or_default()
8523 .as_secs();
8524 if now > plan.expires_unix {
8525 return Err(LinkError::InvalidPack {
8526 message: "conflict bundle expired; rerun sync to obtain current coordinates"
8527 .to_string(),
8528 });
8529 }
8530 let head = v2_verified_head(cfg, &plan.brain)?
8531 .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8532 let pointer = head.pointer.as_ref();
8533 if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8534 || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8535 || pointer.and_then(|value| value.content_root.as_deref())
8536 != plan.remote_content_root.as_deref()
8537 || head.view_kind != plan.view_kind
8538 || head.view_revision != plan.view_revision
8539 {
8540 return Err(LinkError::RemoteAdvancedDuringSync);
8541 }
8542
8543 for file in &plan.files {
8545 let actual = match store.regular_file_exists(Path::new(&file.path))? {
8546 true => Some(content_sha256(&store.read_bounded(
8547 Path::new(&file.path),
8548 file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8549 )?)),
8550 false => None,
8551 };
8552 if actual.as_deref() != file.local.sha256.as_deref() {
8553 return Err(LinkError::InvalidPack {
8554 message: format!(
8555 "local conflict path `{}` changed after the bundle was created",
8556 file.path
8557 ),
8558 });
8559 }
8560 }
8561
8562 let from_source = match &choice {
8563 V2ConflictChoice::From(source) => Some(source.clone()),
8564 _ => None,
8565 };
8566 let result = match choice {
8567 V2ConflictChoice::TakeRemote => {
8568 if bulk_confirmation.is_some() {
8569 return Err(LinkError::InvalidPack {
8570 message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8571 });
8572 }
8573 let current_remote =
8577 files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8578 let _ = v2_take_remote_selection(&plan.files, ¤t_remote)?;
8579 let selected = plan
8580 .files
8581 .iter()
8582 .map(|file| file.path.clone())
8583 .collect::<std::collections::BTreeSet<_>>();
8584 serde_json::to_value(
8585 v2_sync_pull_with_resolution(
8586 cfg,
8587 &plan.brain,
8588 head,
8589 Some(checkout),
8590 Some(&selected),
8591 )?
8592 .report,
8593 )
8594 .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
8595 }
8596 V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
8597 if let Some(source) = from_source.as_ref() {
8598 if plan.files.len() != 1 {
8599 return Err(LinkError::InvalidPack {
8600 message: "--from requires a bundle with exactly one conflict".to_string(),
8601 });
8602 }
8603 let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
8604 if std::str::from_utf8(&candidate).is_err() {
8605 return Err(LinkError::NotUtf8 {
8606 path: source.display().to_string(),
8607 });
8608 }
8609 store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
8610 }
8611 let refreshed_store =
8612 Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8613 message: format!("resolved checkout is not a valid db.md store: {error}"),
8614 })?;
8615 let mut overrides = std::collections::BTreeMap::new();
8616 for file in &plan.files {
8617 let selected_local = match refreshed_store
8618 .regular_file_exists(Path::new(&file.path))?
8619 {
8620 true => Some(content_sha256(
8621 &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
8622 )),
8623 false => None,
8624 };
8625 overrides.insert(
8626 file.path.clone(),
8627 V2ResolutionOverride {
8628 expected_remote: file.remote.sha256.clone(),
8629 selected_local,
8630 },
8631 );
8632 }
8633 v2_sync_push(
8634 cfg,
8635 &plan.brain,
8636 &refreshed_store,
8637 head,
8638 V2SyncPushOptions {
8639 resume_local_policy: true,
8640 bulk_confirmation,
8641 resolution: Some(&overrides),
8642 pulled: None,
8643 withdrawal_paths: &[],
8644 withdrawal_reason: None,
8645 },
8646 )?
8647 }
8648 };
8649
8650 if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
8651 let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8652 message: format!("resolved checkout is not a valid db.md store: {error}"),
8653 })?;
8654 installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8655 }
8656 Ok(json!({
8657 "v": 2,
8658 "class": "auto_converged",
8659 "bundle": bundle,
8660 "receipt": result,
8661 }))
8662}
8663
8664pub fn sync_converge(
8675 cfg: &HubConfig,
8676 brain: &str,
8677 checkout: &Path,
8678 resume_local_policy: bool,
8679) -> LinkResult<Value> {
8680 sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
8681}
8682
8683pub fn sync_converge_with_options(
8685 cfg: &HubConfig,
8686 brain: &str,
8687 checkout: &Path,
8688 resume_local_policy: bool,
8689 bulk_confirmation: Option<&V2BulkConfirmation>,
8690) -> LinkResult<Value> {
8691 sync_converge_with_controls(
8692 cfg,
8693 brain,
8694 checkout,
8695 resume_local_policy,
8696 bulk_confirmation,
8697 &[],
8698 None,
8699 )
8700}
8701
8702pub fn sync_converge_with_controls(
8704 cfg: &HubConfig,
8705 brain: &str,
8706 checkout: &Path,
8707 resume_local_policy: bool,
8708 bulk_confirmation: Option<&V2BulkConfirmation>,
8709 withdrawal_paths: &[String],
8710 withdrawal_reason: Option<&str>,
8711) -> LinkResult<Value> {
8712 require_hardened_filesystem("bidirectional sync")?;
8713 require_safe_ref(brain)?;
8714 let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
8715 message:
8716 "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
8717 .to_string(),
8718 })?;
8719 let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
8720 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8721 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
8722 })?;
8723 let _transaction = store.transaction()?;
8724 let pulled_report = pulled.report.clone();
8725 let pulled_head = pulled.head.clone();
8726 let mut result = v2_sync_push(
8727 cfg,
8728 brain,
8729 &store,
8730 pulled_head,
8731 V2SyncPushOptions {
8732 resume_local_policy,
8733 bulk_confirmation,
8734 resolution: None,
8735 pulled: Some(pulled),
8736 withdrawal_paths,
8737 withdrawal_reason,
8738 },
8739 )?;
8740 if let Some(object) = result.as_object_mut() {
8741 object.insert("pulled_files".to_string(), json!(pulled_report.files));
8742 object.insert("checkout".to_string(), Value::String(pulled_report.dest));
8743 object.insert(
8744 "mode".to_string(),
8745 Value::String("bidirectional".to_string()),
8746 );
8747 }
8748 Ok(result)
8749}
8750
8751pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
8757 require_hardened_filesystem("sync pull")?;
8758 require_safe_ref(brain)?;
8759 if let Some(head) = v2_verified_head(cfg, brain)? {
8760 return v2_sync_pull(cfg, brain, head, out);
8761 }
8762 legacy_sync_pull(cfg, brain, out)
8763}
8764
8765#[cfg(windows)]
8766fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
8767 Err(LinkError::UnsupportedPlatform {
8768 operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
8769 })
8770}
8771
8772#[cfg(not(windows))]
8773fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
8774 let remote = verified_remote_head(cfg, brain, false)?;
8775 if !remote.head.verified {
8776 return Err(invalid_feed(
8777 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
8778 ));
8779 }
8780 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
8781 let path = format!(
8782 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
8783 remote.head.seq
8784 );
8785 let body = ensure_ok(
8786 request(cfg, "GET", &path, None, Auth::Required)?,
8787 "sync pull",
8788 )?;
8789 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
8790 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
8791 {
8792 return Err(invalid_feed(
8793 "export response is not bound to the verified snapshot token",
8794 ));
8795 }
8796
8797 let remote_slug = body
8798 .get("slug")
8799 .and_then(Value::as_str)
8800 .filter(|slug| is_safe_slug(slug));
8801 let slug = remote_slug
8802 .or_else(|| is_safe_slug(brain).then_some(brain))
8803 .unwrap_or("brain")
8804 .to_string();
8805 let brain_id = body
8806 .get("brain")
8807 .and_then(Value::as_str)
8808 .unwrap_or(&remote.head.brain)
8809 .to_string();
8810 if brain_id != remote.head.brain {
8811 return Err(invalid_feed(
8812 "export response names a different brain than the verified head",
8813 ));
8814 }
8815 let head_seq = remote.head.seq;
8816 let dest: PathBuf = match out {
8817 Some(p) => p.to_path_buf(),
8818 None => PathBuf::from(&slug),
8819 };
8820 let entries = if head_seq == 0 {
8821 let files = body
8822 .get("files")
8823 .and_then(Value::as_array)
8824 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
8825 if !files.is_empty() || body.get("url").is_some() {
8826 return Err(invalid_feed(
8827 "empty signed feed cannot authorize non-empty exported content",
8828 ));
8829 }
8830 Vec::new()
8831 } else {
8832 let signed_head = remote
8833 .head_entry
8834 .as_ref()
8835 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
8836 let expected = &signed_head.entry.pack_sha256;
8837 if !is_sha256(expected) {
8838 return Err(invalid_feed(
8839 "signed head carries an invalid snapshot pack digest",
8840 ));
8841 }
8842 if let Some(url) = body.get("url").and_then(Value::as_str) {
8843 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
8844 return Err(invalid_feed(
8845 "export pack digest does not match the signed head entry",
8846 ));
8847 }
8848 let bytes = get_presigned(cfg, url)?;
8849 let actual = format!("{:x}", Sha256::digest(&bytes));
8850 if actual != *expected {
8851 return Err(LinkError::InvalidPack {
8852 message: "downloaded pack does not match the signed snapshot digest"
8853 .to_string(),
8854 });
8855 }
8856 let entries = parse_store_pack(bytes)?;
8857 if signed_head.entry.kind == "push" {
8858 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
8859 }
8860 entries
8861 } else {
8862 if signed_head.entry.kind != "push" {
8863 return Err(invalid_feed(
8864 "delta snapshots must export the exact signed pack",
8865 ));
8866 }
8867 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
8868 invalid_feed("verified snapshot export carried neither a pack nor files")
8869 })?;
8870 let mut entries = Vec::with_capacity(files.len());
8871 for file in files {
8872 let path = file
8873 .get("path")
8874 .and_then(Value::as_str)
8875 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
8876 let content = file
8877 .get("content")
8878 .and_then(Value::as_str)
8879 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
8880 entries.push((path.to_string(), content.as_bytes().to_vec()));
8881 }
8882 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
8883 entries
8884 }
8885 };
8886
8887 let mut seen = std::collections::HashSet::new();
8889 for (path, _) in &entries {
8890 if !safe_store_rel_path(path) {
8891 return Err(LinkError::UnsafePath { path: path.clone() });
8892 }
8893 if !seen.insert(path) {
8894 return Err(LinkError::InvalidPack {
8895 message: format!("duplicate path `{path}`"),
8896 });
8897 }
8898 }
8899 let pulled: std::collections::BTreeSet<&str> =
8902 entries.iter().map(|(p, _)| p.as_str()).collect();
8903 let mut extra_local = Vec::new();
8904 if let Ok(store) = Store::open(&dest) {
8905 if let Ok(walked) = store.walk() {
8906 for rel in walked {
8907 let rel_str = rel.to_string_lossy().replace('\\', "/");
8908 if !pulled.contains(rel_str.as_str()) {
8909 extra_local.push(rel_str);
8910 }
8911 }
8912 }
8913 }
8914 #[cfg(unix)]
8915 install_pulled_snapshot(&dest, &entries)?;
8916
8917 Ok(PullReport {
8918 brain: brain_id,
8919 slug,
8920 head_seq,
8921 files: entries.len(),
8922 dest: dest.to_string_lossy().into_owned(),
8923 extra_local,
8924 sync_status: "synced".to_string(),
8925 })
8926}
8927
8928#[cfg(unix)]
8929fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
8930 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
8931 path: display.to_string(),
8932 })
8933}
8934
8935#[cfg(unix)]
8936fn open_dir_at(
8937 parent: std::os::fd::RawFd,
8938 name: &std::ffi::CStr,
8939 display: &str,
8940) -> LinkResult<std::fs::File> {
8941 use std::os::fd::FromRawFd as _;
8942 let fd = unsafe {
8943 libc::openat(
8944 parent,
8945 name.as_ptr(),
8946 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8947 )
8948 };
8949 if fd < 0 {
8950 return Err(LinkError::UnsafePath {
8951 path: display.to_string(),
8952 });
8953 }
8954 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
8955}
8956
8957#[cfg(unix)]
8961fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
8962 use std::os::fd::AsRawFd as _;
8963
8964 #[cfg(target_os = "macos")]
8968 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
8969 .into_iter()
8970 .find_map(|(alias, real)| {
8971 path.strip_prefix(alias)
8972 .ok()
8973 .map(|rest| Path::new(real).join(rest))
8974 })
8975 .unwrap_or_else(|| path.to_path_buf());
8976 #[cfg(not(target_os = "macos"))]
8977 let normalized = path.to_path_buf();
8978
8979 let start = if normalized.is_absolute() {
8980 std::fs::File::open("/")?
8981 } else {
8982 std::fs::File::open(".")?
8983 };
8984 let mut directory = start;
8985 for component in normalized.components() {
8986 use std::path::Component;
8987 let name = match component {
8988 Component::RootDir | Component::CurDir => continue,
8989 Component::Normal(name) => name,
8990 Component::ParentDir | Component::Prefix(_) => {
8991 return Err(LinkError::UnsafePath {
8992 path: path.display().to_string(),
8993 });
8994 }
8995 };
8996 use std::os::unix::ffi::OsStrExt as _;
8997 let name = c_name(name.as_bytes(), &path.display().to_string())?;
8998 if create {
8999 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9000 if made != 0 {
9001 let error = std::io::Error::last_os_error();
9002 if error.raw_os_error() != Some(libc::EEXIST) {
9003 return Err(error.into());
9004 }
9005 }
9006 }
9007 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
9008 }
9009 Ok(directory)
9010}
9011
9012#[cfg(unix)]
9013fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9014 open_dir_path_nofollow(path, true)
9015}
9016
9017#[cfg(unix)]
9018fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9019 open_dir_path_nofollow(path, false)
9020}
9021
9022#[cfg(unix)]
9023fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
9024 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9025 let result =
9026 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
9027 if result == 0 {
9028 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
9029 }
9030 let error = std::io::Error::last_os_error();
9031 if error.kind() == std::io::ErrorKind::NotFound {
9032 Ok(None)
9033 } else {
9034 Err(error.into())
9035 }
9036}
9037
9038#[cfg(unix)]
9039fn create_dir_exclusive_at(
9040 parent: std::os::fd::RawFd,
9041 name: &std::ffi::CStr,
9042 display: &str,
9043) -> LinkResult<std::fs::File> {
9044 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
9045 if made != 0 {
9046 return Err(LinkError::UnsafePath {
9047 path: display.to_string(),
9048 });
9049 }
9050 open_dir_at(parent, name, display)
9051}
9052
9053#[cfg(unix)]
9054fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
9055 use std::os::fd::AsRawFd as _;
9056
9057 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
9058 if duplicate < 0 {
9059 return Err(std::io::Error::last_os_error().into());
9060 }
9061 let stream = unsafe { libc::fdopendir(duplicate) };
9062 if stream.is_null() {
9063 let error = std::io::Error::last_os_error();
9064 unsafe {
9065 libc::close(duplicate);
9066 }
9067 return Err(error.into());
9068 }
9069 let mut names = Vec::new();
9070 loop {
9071 let entry = unsafe { libc::readdir(stream) };
9072 if entry.is_null() {
9073 break;
9074 }
9075 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
9076 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
9077 names.push(raw.to_owned());
9078 }
9079 }
9080 if unsafe { libc::closedir(stream) } != 0 {
9081 return Err(std::io::Error::last_os_error().into());
9082 }
9083 Ok(names)
9084}
9085
9086#[cfg(unix)]
9089fn remove_tree_at(
9090 parent: std::os::fd::RawFd,
9091 name: &std::ffi::CStr,
9092 display: &str,
9093) -> LinkResult<()> {
9094 use std::os::fd::AsRawFd as _;
9095
9096 match entry_is_dir_at(parent, name)? {
9097 None => return Ok(()),
9098 Some(false) => {
9099 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
9100 return Err(std::io::Error::last_os_error().into());
9101 }
9102 }
9103 Some(true) => {
9104 let directory = open_dir_at(parent, name, display)?;
9105 for child in directory_entry_names(&directory)? {
9106 let child_display =
9107 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
9108 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
9109 }
9110 drop(directory);
9111 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
9112 return Err(std::io::Error::last_os_error().into());
9113 }
9114 }
9115 }
9116 Ok(())
9117}
9118
9119#[cfg(unix)]
9123fn clone_tree_contents(
9124 source: &std::fs::File,
9125 destination: &std::fs::File,
9126 display: &str,
9127) -> LinkResult<()> {
9128 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9129
9130 for name in directory_entry_names(source)? {
9131 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9132 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9133 if unsafe {
9134 libc::fstatat(
9135 source.as_raw_fd(),
9136 name.as_ptr(),
9137 &mut stat,
9138 libc::AT_SYMLINK_NOFOLLOW,
9139 )
9140 } != 0
9141 {
9142 return Err(std::io::Error::last_os_error().into());
9143 }
9144 match stat.st_mode & libc::S_IFMT {
9145 libc::S_IFDIR => {
9146 if unsafe {
9147 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
9148 } != 0
9149 {
9150 return Err(std::io::Error::last_os_error().into());
9151 }
9152 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
9153 let destination_child =
9154 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
9155 clone_tree_contents(&source_child, &destination_child, &child_display)?;
9156 destination_child.sync_all()?;
9157 }
9158 libc::S_IFREG => {
9159 let source_fd = unsafe {
9160 libc::openat(
9161 source.as_raw_fd(),
9162 name.as_ptr(),
9163 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9164 )
9165 };
9166 if source_fd < 0 {
9167 return Err(std::io::Error::last_os_error().into());
9168 }
9169 let destination_fd = unsafe {
9170 libc::openat(
9171 destination.as_raw_fd(),
9172 name.as_ptr(),
9173 libc::O_WRONLY
9174 | libc::O_CREAT
9175 | libc::O_EXCL
9176 | libc::O_CLOEXEC
9177 | libc::O_NOFOLLOW,
9178 (stat.st_mode & 0o777) as libc::c_uint,
9179 )
9180 };
9181 if destination_fd < 0 {
9182 unsafe {
9183 libc::close(source_fd);
9184 }
9185 return Err(std::io::Error::last_os_error().into());
9186 }
9187 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
9188 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
9189 std::io::copy(&mut input, &mut output)?;
9190 output.sync_all()?;
9191 }
9192 libc::S_IFLNK => {
9193 let mut target = vec![0_u8; 4097];
9194 let length = unsafe {
9195 libc::readlinkat(
9196 source.as_raw_fd(),
9197 name.as_ptr(),
9198 target.as_mut_ptr().cast(),
9199 target.len(),
9200 )
9201 };
9202 if length < 0 || length as usize >= target.len() {
9203 return Err(LinkError::UnsafePath {
9204 path: child_display,
9205 });
9206 }
9207 target.truncate(length as usize);
9208 let target = c_name(&target, &child_display)?;
9209 if unsafe {
9210 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
9211 } != 0
9212 {
9213 return Err(std::io::Error::last_os_error().into());
9214 }
9215 }
9216 _ => {
9217 return Err(LinkError::UnsafePath {
9218 path: child_display,
9219 });
9220 }
9221 }
9222 }
9223 destination.sync_all()?;
9224 Ok(())
9225}
9226
9227#[cfg(target_os = "linux")]
9228fn install_stage_at(
9229 parent: std::os::fd::RawFd,
9230 stage: &std::ffi::CStr,
9231 dest: &std::ffi::CStr,
9232 dest_exists: bool,
9233) -> LinkResult<()> {
9234 let flags = if dest_exists {
9235 libc::RENAME_EXCHANGE
9236 } else {
9237 libc::RENAME_NOREPLACE
9238 };
9239 let result = unsafe {
9243 libc::syscall(
9244 libc::SYS_renameat2,
9245 parent,
9246 stage.as_ptr(),
9247 parent,
9248 dest.as_ptr(),
9249 flags,
9250 )
9251 };
9252 if result == 0 {
9253 Ok(())
9254 } else {
9255 Err(std::io::Error::last_os_error().into())
9256 }
9257}
9258
9259#[cfg(target_os = "macos")]
9260fn install_stage_at(
9261 parent: std::os::fd::RawFd,
9262 stage: &std::ffi::CStr,
9263 dest: &std::ffi::CStr,
9264 dest_exists: bool,
9265) -> LinkResult<()> {
9266 let flags = if dest_exists {
9267 libc::RENAME_SWAP
9268 } else {
9269 libc::RENAME_EXCL
9270 };
9271 let result =
9272 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
9273 if result == 0 {
9274 Ok(())
9275 } else {
9276 Err(std::io::Error::last_os_error().into())
9277 }
9278}
9279
9280#[cfg(unix)]
9281fn write_pull_entries_beneath_dir(
9282 root: &std::fs::File,
9283 entries: &[(String, Vec<u8>)],
9284) -> LinkResult<()> {
9285 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9286
9287 for (path, content) in entries {
9288 let components: Vec<&str> = path.split('/').collect();
9289 let (leaf, parents) = components
9290 .split_last()
9291 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9292 let mut directory = root.try_clone()?;
9293 for component in parents {
9294 let name = c_name(component.as_bytes(), path)?;
9295 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9296 if made != 0 {
9297 let error = std::io::Error::last_os_error();
9298 if error.raw_os_error() != Some(libc::EEXIST) {
9299 return Err(error.into());
9300 }
9301 }
9302 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9303 }
9304
9305 let leaf_name = c_name(leaf.as_bytes(), path)?;
9306 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9307 let inspected = unsafe {
9308 libc::fstatat(
9309 directory.as_raw_fd(),
9310 leaf_name.as_ptr(),
9311 &mut existing,
9312 libc::AT_SYMLINK_NOFOLLOW,
9313 )
9314 };
9315 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
9316 return Err(LinkError::UnsafePath { path: path.clone() });
9317 }
9318
9319 let nonce = std::time::SystemTime::now()
9320 .duration_since(std::time::UNIX_EPOCH)
9321 .unwrap_or_default()
9322 .as_nanos();
9323 let temp_name = format!(
9324 ".dbmd-pull-{}-{nonce}-{}",
9325 std::process::id(),
9326 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
9327 );
9328 let temp = c_name(temp_name.as_bytes(), path)?;
9329 let fd = unsafe {
9330 libc::openat(
9331 directory.as_raw_fd(),
9332 temp.as_ptr(),
9333 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9334 0o600,
9335 )
9336 };
9337 if fd < 0 {
9338 return Err(std::io::Error::last_os_error().into());
9339 }
9340 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9341 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
9342 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9343 return Err(error.into());
9344 }
9345 drop(file);
9346 let renamed = unsafe {
9347 libc::renameat(
9348 directory.as_raw_fd(),
9349 temp.as_ptr(),
9350 directory.as_raw_fd(),
9351 leaf_name.as_ptr(),
9352 )
9353 };
9354 if renamed != 0 {
9355 let error = std::io::Error::last_os_error();
9356 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9357 return Err(error.into());
9358 }
9359 directory.sync_all()?;
9360 }
9361 root.sync_all()?;
9362 Ok(())
9363}
9364
9365#[cfg(unix)]
9366fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9367 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9368
9369 let path = &entry.path;
9370 let components: Vec<&str> = path.split('/').collect();
9371 let (leaf, parents) = components
9372 .split_last()
9373 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9374 let mut directory = root.try_clone()?;
9375 for component in parents {
9376 let name = c_name(component.as_bytes(), path)?;
9377 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9378 if made != 0 {
9379 let error = std::io::Error::last_os_error();
9380 if error.raw_os_error() != Some(libc::EEXIST) {
9381 return Err(error.into());
9382 }
9383 }
9384 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9385 }
9386 let leaf_name = c_name(leaf.as_bytes(), path)?;
9387 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9388 if unsafe {
9389 libc::fstatat(
9390 directory.as_raw_fd(),
9391 leaf_name.as_ptr(),
9392 &mut existing,
9393 libc::AT_SYMLINK_NOFOLLOW,
9394 )
9395 } == 0
9396 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
9397 {
9398 return Err(LinkError::UnsafePath { path: path.clone() });
9399 }
9400 let nonce = SystemTime::now()
9401 .duration_since(UNIX_EPOCH)
9402 .unwrap_or_default()
9403 .as_nanos();
9404 let temp_name = format!(
9405 ".dbmd-pull-{}-{nonce}-{}",
9406 std::process::id(),
9407 content_sha256(path.as_bytes())
9408 );
9409 let temp = c_name(temp_name.as_bytes(), path)?;
9410 let fd = unsafe {
9411 libc::openat(
9412 directory.as_raw_fd(),
9413 temp.as_ptr(),
9414 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9415 0o600,
9416 )
9417 };
9418 if fd < 0 {
9419 return Err(std::io::Error::last_os_error().into());
9420 }
9421 let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
9422 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
9423 let mut digest = Sha256::new();
9424 let mut total = 0_u64;
9425 let mut buffer = [0_u8; 64 * 1024];
9426 let copied = (|| -> std::io::Result<()> {
9427 loop {
9428 let read = input.read(&mut buffer)?;
9429 if read == 0 {
9430 break;
9431 }
9432 total = total.saturating_add(read as u64);
9433 if total > entry.bytes {
9434 return Err(std::io::Error::new(
9435 std::io::ErrorKind::InvalidData,
9436 "staged sync source grew beyond its verified length",
9437 ));
9438 }
9439 digest.update(&buffer[..read]);
9440 output.write_all(&buffer[..read])?;
9441 }
9442 Ok(())
9443 })();
9444 if let Err(error) = copied {
9445 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9446 return Err(error.into());
9447 }
9448 drop(output);
9449 if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
9450 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9451 return Err(invalid_feed(
9452 "private staged sync source failed final integrity verification",
9453 ));
9454 }
9455 if unsafe {
9456 libc::renameat(
9457 directory.as_raw_fd(),
9458 temp.as_ptr(),
9459 directory.as_raw_fd(),
9460 leaf_name.as_ptr(),
9461 )
9462 } != 0
9463 {
9464 let error = std::io::Error::last_os_error();
9465 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9466 return Err(error.into());
9467 }
9468 Ok(())
9469}
9470
9471#[cfg(unix)]
9472fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9473 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9474
9475 let path = &entry.path;
9476 let components: Vec<&str> = path.split('/').collect();
9477 let (leaf, parents) = components
9478 .split_last()
9479 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9480 let mut directory = root.try_clone()?;
9481 for component in parents {
9482 directory = open_dir_at(
9483 directory.as_raw_fd(),
9484 &c_name(component.as_bytes(), path)?,
9485 path,
9486 )?;
9487 }
9488 let leaf = c_name(leaf.as_bytes(), path)?;
9489 let fd = unsafe {
9490 libc::openat(
9491 directory.as_raw_fd(),
9492 leaf.as_ptr(),
9493 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9494 )
9495 };
9496 if fd < 0 {
9497 return Err(std::io::Error::last_os_error().into());
9498 }
9499 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9500 if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
9501 return Err(invalid_feed(
9502 "private pull stage changed before its durability barrier",
9503 ));
9504 }
9505 file.sync_all()?;
9506 Ok(())
9507}
9508
9509#[cfg(unix)]
9510fn run_pull_source_workers(
9511 root: &std::fs::File,
9512 entries: &[V2StagedFile],
9513 operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
9514) -> LinkResult<()> {
9515 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9516
9517 let next = AtomicUsize::new(0);
9518 let failed = AtomicBool::new(false);
9519 let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
9520 let mut first_error = None;
9521 std::thread::scope(|scope| {
9522 let (sender, receiver) = std::sync::mpsc::channel();
9523 for _ in 0..worker_count {
9524 let sender = sender.clone();
9525 let next = &next;
9526 let failed = &failed;
9527 scope.spawn(move || {
9528 while !failed.load(Ordering::Acquire) {
9529 let index = next.fetch_add(1, Ordering::Relaxed);
9530 let Some(entry) = entries.get(index) else {
9531 break;
9532 };
9533 let result = operation(root, entry);
9534 if result.is_err() {
9535 failed.store(true, Ordering::Release);
9536 }
9537 if sender.send(result).is_err() {
9538 break;
9539 }
9540 }
9541 });
9542 }
9543 drop(sender);
9544 for result in receiver {
9545 if let Err(error) = result {
9546 if first_error.is_none() {
9547 first_error = Some(error);
9548 }
9549 }
9550 }
9551 });
9552 if let Some(error) = first_error {
9553 return Err(error);
9554 }
9555 if next.load(Ordering::Relaxed) < entries.len() {
9556 return Err(invalid_feed(
9557 "a bounded pull worker stopped before reporting every file",
9558 ));
9559 }
9560 Ok(())
9561}
9562
9563#[cfg(unix)]
9564fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9565 use std::os::fd::AsRawFd as _;
9566
9567 for name in directory_entry_names(root)? {
9568 if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9569 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9570 let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9571 sync_pull_directory_tree(&child, &child_display)?;
9572 }
9573 }
9574 root.sync_all()?;
9575 Ok(())
9576}
9577
9578#[cfg(unix)]
9579fn write_pull_sources_beneath_dir(
9580 root: &std::fs::File,
9581 entries: &[V2StagedFile],
9582) -> LinkResult<()> {
9583 run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
9590 run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
9591 sync_pull_directory_tree(root, "v2 pull stage")
9592}
9593
9594#[cfg(unix)]
9595fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
9596 use std::os::fd::AsRawFd as _;
9597 for path in paths {
9598 if !safe_store_rel_path(path) {
9599 return Err(LinkError::UnsafePath { path: path.clone() });
9600 }
9601 let components = path.split('/').collect::<Vec<_>>();
9602 let Some((leaf, parents)) = components.split_last() else {
9603 return Err(LinkError::UnsafePath { path: path.clone() });
9604 };
9605 let mut directory = root.try_clone()?;
9606 let mut missing = false;
9607 for component in parents {
9608 let name = c_name(component.as_bytes(), path)?;
9609 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
9610 None => {
9611 missing = true;
9612 break;
9613 }
9614 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
9615 Some(true) => {
9616 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9617 }
9618 }
9619 }
9620 if missing {
9621 continue;
9622 }
9623 let leaf = c_name(leaf.as_bytes(), path)?;
9624 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
9625 None => {}
9626 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
9627 Some(false) => {
9628 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
9629 return Err(std::io::Error::last_os_error().into());
9630 }
9631 directory.sync_all()?;
9632 }
9633 }
9634 }
9635 Ok(())
9636}
9637
9638#[cfg(unix)]
9639fn install_pulled_delta(
9640 dest: &Path,
9641 entries: &[(String, Vec<u8>)],
9642 deleted: &[String],
9643 rebuild_indexes: bool,
9644) -> LinkResult<()> {
9645 use ring::rand::SecureRandom as _;
9646 use std::os::fd::AsRawFd as _;
9647 use std::os::unix::ffi::OsStrExt as _;
9648
9649 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9650 let name = dest
9651 .file_name()
9652 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9653 .ok_or_else(|| LinkError::UnsafePath {
9654 path: dest.display().to_string(),
9655 })?;
9656 let parent_dir = open_or_create_dir_nofollow(parent)?;
9657 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9658 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9659 None => false,
9660 Some(true) => true,
9661 Some(false) => {
9662 return Err(LinkError::UnsafePath {
9663 path: dest.display().to_string(),
9664 });
9665 }
9666 };
9667
9668 let mut nonce = [0_u8; 16];
9669 ring::rand::SystemRandom::new()
9670 .fill(&mut nonce)
9671 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9672 let stage_label = format!(
9673 ".{}.dbmd-pull-stage-{}",
9674 name.to_string_lossy(),
9675 URL_SAFE_NO_PAD.encode(nonce)
9676 );
9677 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9678 let stage_dir = create_dir_exclusive_at(
9679 parent_dir.as_raw_fd(),
9680 &stage_name,
9681 &dest.display().to_string(),
9682 )?;
9683
9684 let prepared = (|| -> LinkResult<()> {
9685 if dest_exists {
9686 let live = open_dir_at(
9687 parent_dir.as_raw_fd(),
9688 &dest_name,
9689 &dest.display().to_string(),
9690 )?;
9691 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9692 }
9693 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9694 write_pull_entries_beneath_dir(&stage_dir, entries)?;
9695 if rebuild_indexes {
9696 let stage_store =
9697 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
9698 .map_err(|error| LinkError::InvalidPack {
9699 message: format!("v2 staging tree is not a valid db.md store: {error}"),
9700 })?;
9701 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
9702 LinkError::InvalidPack {
9703 message: format!("could not materialize v2 local catalogs: {error}"),
9704 }
9705 })?;
9706 }
9707 stage_dir.sync_all()?;
9708 Ok(())
9709 })();
9710 if let Err(error) = prepared {
9711 let _ = remove_tree_at(
9712 parent_dir.as_raw_fd(),
9713 &stage_name,
9714 &dest.display().to_string(),
9715 );
9716 return Err(error);
9717 }
9718
9719 if let Err(error) =
9720 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
9721 {
9722 let _ = remove_tree_at(
9723 parent_dir.as_raw_fd(),
9724 &stage_name,
9725 &dest.display().to_string(),
9726 );
9727 return Err(error);
9728 }
9729 parent_dir.sync_all()?;
9730 if dest_exists {
9731 let _ = remove_tree_at(
9735 parent_dir.as_raw_fd(),
9736 &stage_name,
9737 &dest.display().to_string(),
9738 );
9739 let _ = parent_dir.sync_all();
9740 }
9741 Ok(())
9742}
9743
9744#[cfg(unix)]
9745fn install_pulled_delta_sources(
9746 dest: &Path,
9747 entries: &[V2StagedFile],
9748 deleted: &[String],
9749 rebuild_indexes: bool,
9750 _previous: Option<&V2SyncBaseline>,
9751 _next: &V2VerifiedHead,
9752) -> LinkResult<()> {
9753 use ring::rand::SecureRandom as _;
9754 use std::os::fd::AsRawFd as _;
9755 use std::os::unix::ffi::OsStrExt as _;
9756
9757 if let Ok(store) = Store::open_strict(dest) {
9761 return install_established_v2_delta(
9762 store,
9763 entries,
9764 deleted,
9765 rebuild_indexes,
9766 _previous,
9767 _next,
9768 );
9769 }
9770
9771 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9772 let name = dest
9773 .file_name()
9774 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9775 .ok_or_else(|| LinkError::UnsafePath {
9776 path: dest.display().to_string(),
9777 })?;
9778 let parent_dir = open_or_create_dir_nofollow(parent)?;
9779 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9780 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9781 None => false,
9782 Some(true) => true,
9783 Some(false) => {
9784 return Err(LinkError::UnsafePath {
9785 path: dest.display().to_string(),
9786 })
9787 }
9788 };
9789 let mut nonce = [0_u8; 16];
9790 ring::rand::SystemRandom::new()
9791 .fill(&mut nonce)
9792 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9793 let stage_label = format!(
9794 ".{}.dbmd-pull-stage-{}",
9795 name.to_string_lossy(),
9796 URL_SAFE_NO_PAD.encode(nonce)
9797 );
9798 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9799 let stage_dir = create_dir_exclusive_at(
9800 parent_dir.as_raw_fd(),
9801 &stage_name,
9802 &dest.display().to_string(),
9803 )?;
9804 let prepared = (|| -> LinkResult<()> {
9805 if dest_exists {
9806 let live = open_dir_at(
9807 parent_dir.as_raw_fd(),
9808 &dest_name,
9809 &dest.display().to_string(),
9810 )?;
9811 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9812 }
9813 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9814 write_pull_sources_beneath_dir(&stage_dir, entries)?;
9815 if rebuild_indexes {
9816 let stage_store =
9817 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
9818 .map_err(|error| LinkError::InvalidPack {
9819 message: format!("v2 staging tree is not a valid db.md store: {error}"),
9820 })?;
9821 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
9822 LinkError::InvalidPack {
9823 message: format!("could not materialize v2 local catalogs: {error}"),
9824 }
9825 })?;
9826 }
9827 stage_dir.sync_all()?;
9828 Ok(())
9829 })();
9830 if let Err(error) = prepared {
9831 let _ = remove_tree_at(
9832 parent_dir.as_raw_fd(),
9833 &stage_name,
9834 &dest.display().to_string(),
9835 );
9836 return Err(error);
9837 }
9838 if let Err(error) =
9839 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
9840 {
9841 let _ = remove_tree_at(
9842 parent_dir.as_raw_fd(),
9843 &stage_name,
9844 &dest.display().to_string(),
9845 );
9846 return Err(error);
9847 }
9848 parent_dir.sync_all()?;
9849 if dest_exists {
9850 let _ = remove_tree_at(
9851 parent_dir.as_raw_fd(),
9852 &stage_name,
9853 &dest.display().to_string(),
9854 );
9855 let _ = parent_dir.sync_all();
9856 }
9857 Ok(())
9858}
9859
9860#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9861struct V2PullCoordinate {
9862 head_seq: Option<u64>,
9863 commit_hash: Option<String>,
9864 view_kind: Option<String>,
9865 view_revision: Option<String>,
9866}
9867
9868#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9869struct V2PullFileCoordinate {
9870 sha256: String,
9871 bytes: u64,
9872}
9873
9874#[derive(Debug, Clone, Deserialize, Serialize)]
9875struct V2PullJournalEntry {
9876 path: String,
9877 old: Option<V2PullFileCoordinate>,
9878 new: Option<V2PullFileCoordinate>,
9879 backup: Option<String>,
9880}
9881
9882#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9883#[serde(rename_all = "snake_case")]
9884enum V2PullPhase {
9885 Preparing,
9886 Ready,
9887}
9888
9889#[derive(Debug, Clone, Deserialize, Serialize)]
9890struct V2PullJournal {
9891 v: u8,
9892 phase: V2PullPhase,
9893 brain: String,
9894 previous: V2PullCoordinate,
9895 next: V2PullCoordinate,
9896 backup_dir: String,
9897 entries: Vec<V2PullJournalEntry>,
9898}
9899
9900const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
9901
9902fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
9903 V2PullCoordinate {
9904 head_seq: baseline.and_then(|value| value.head_seq),
9905 commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
9906 view_kind: baseline.and_then(|value| value.view_kind.clone()),
9907 view_revision: baseline.and_then(|value| value.view_revision.clone()),
9908 }
9909}
9910
9911fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
9912 V2PullCoordinate {
9913 head_seq: head.pointer.as_ref().map(|value| value.seq),
9914 commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
9915 view_kind: Some(head.view_kind.clone()),
9916 view_revision: Some(head.view_revision.clone()),
9917 }
9918}
9919
9920fn v2_pull_file_coordinate(
9921 store: &Store,
9922 path: &str,
9923 limit: u64,
9924) -> LinkResult<Option<V2PullFileCoordinate>> {
9925 let file = match store.open_regular(Path::new(path)) {
9926 Ok(file) => file,
9927 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9928 Err(error) => return Err(error.into()),
9929 };
9930 let bytes = file.metadata()?.len();
9931 if bytes > limit || bytes > MAX_STORE_BYTES {
9932 return Err(invalid_feed(
9933 "pull transaction file exceeds its declared bound",
9934 ));
9935 }
9936 Ok(Some(V2PullFileCoordinate {
9937 sha256: content_sha256_reader(file)?,
9938 bytes,
9939 }))
9940}
9941
9942fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
9943 let mut bytes = serde_json::to_vec_pretty(journal)
9944 .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
9945 bytes.push(b'\n');
9946 Ok(bytes)
9947}
9948
9949fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
9950 let backup_prefix = ".dbmd/pull-backup-";
9951 let suffix = journal
9952 .backup_dir
9953 .strip_prefix(backup_prefix)
9954 .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
9955 let mut paths = std::collections::BTreeSet::new();
9956 if journal.v != 1
9957 || !crate::ulid::is_ulid(&journal.brain)
9958 || !crate::ulid::is_ulid(suffix)
9959 || journal.entries.is_empty()
9960 || journal.entries.len() > MAX_PUSH_FILES + 4
9961 || journal.previous == journal.next
9962 {
9963 return Err(invalid_feed("v2 pull journal failed validation"));
9964 }
9965 for (index, entry) in journal.entries.iter().enumerate() {
9966 if !safe_store_rel_path(&entry.path)
9967 || entry.path == V2_PULL_JOURNAL
9968 || entry.path.starts_with(backup_prefix)
9969 || !paths.insert(entry.path.clone())
9970 || (entry.old.is_none() && entry.new.is_none())
9971 || entry
9972 .old
9973 .iter()
9974 .chain(entry.new.iter())
9975 .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
9976 || entry.backup.as_deref()
9977 != entry
9978 .old
9979 .as_ref()
9980 .map(|_| format!("{index:08x}"))
9981 .as_deref()
9982 {
9983 return Err(invalid_feed("v2 pull journal entry failed validation"));
9984 }
9985 }
9986 Ok(())
9987}
9988
9989fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
9990 #[cfg(unix)]
9991 {
9992 use std::os::unix::fs::PermissionsExt as _;
9993 match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
9994 Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
9995 return Err(invalid_feed(
9996 "v2 pull journal is accessible to group/other; set mode 0600",
9997 ));
9998 }
9999 Ok(_) => {}
10000 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10001 Err(error) => return Err(error.into()),
10002 }
10003 }
10004 let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
10005 Ok(bytes) => bytes,
10006 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10007 Err(error) => return Err(error.into()),
10008 };
10009 let journal: V2PullJournal =
10010 serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
10011 validate_v2_pull_journal(&journal)?;
10012 Ok(Some(journal))
10013}
10014
10015fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10016 match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
10020 Ok(()) => {}
10021 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
10022 Err(error) => return Err(error.into()),
10023 }
10024 match store.remove_private_tree(Path::new(&journal.backup_dir)) {
10025 Ok(()) => Ok(()),
10026 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
10027 Err(error) => Err(error.into()),
10028 }
10029}
10030
10031fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
10032 let names = match store.directory_names(Path::new(".dbmd")) {
10033 Ok(names) => names,
10034 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
10035 Err(error) => return Err(error.into()),
10036 };
10037 for name in names {
10038 let Some(name) = name.to_str() else {
10039 continue;
10040 };
10041 let Some(suffix) = name.strip_prefix("pull-backup-") else {
10042 continue;
10043 };
10044 if crate::ulid::is_ulid(suffix) {
10045 store.remove_private_tree(&Path::new(".dbmd").join(name))?;
10046 }
10047 }
10048 Ok(())
10049}
10050
10051fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10052 for entry in &journal.entries {
10054 let limit = entry
10055 .old
10056 .as_ref()
10057 .into_iter()
10058 .chain(entry.new.iter())
10059 .map(|value| value.bytes)
10060 .max()
10061 .unwrap_or(0);
10062 let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
10063 if current != entry.old && current != entry.new {
10064 return Err(LinkError::InvalidPack {
10065 message: format!(
10066 "cannot recover interrupted pull because `{}` changed afterward",
10067 entry.path
10068 ),
10069 });
10070 }
10071 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10072 let path = Path::new(&journal.backup_dir).join(backup);
10073 let file = store.open_regular(&path)?;
10074 if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
10075 return Err(invalid_feed("v2 pull recovery backup failed verification"));
10076 }
10077 }
10078 }
10079 for entry in journal.entries.iter().rev() {
10080 match (&entry.old, &entry.backup) {
10081 (Some(old), Some(backup)) => {
10082 let bytes =
10083 store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
10084 store.write_atomic(Path::new(&entry.path), &bytes)?;
10085 }
10086 (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
10087 store.remove_file(Path::new(&entry.path))?;
10088 }
10089 (None, None) => {}
10090 _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
10091 }
10092 }
10093 crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
10094 message: format!("could not rebuild catalogs after pull recovery: {error}"),
10095 })?;
10096 cleanup_v2_pull_journal(store, journal)
10097}
10098
10099fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
10100 let Ok(store) = Store::open_strict(dest) else {
10101 return Ok(());
10102 };
10103 if let Some(journal) = load_v2_pull_journal(&store)? {
10104 if journal.brain != brain {
10105 return Err(invalid_feed("v2 pull journal belongs to another brain"));
10106 }
10107 if journal.phase == V2PullPhase::Preparing {
10108 cleanup_v2_pull_journal(&store, &journal)?;
10109 } else {
10110 let baseline = load_v2_baseline(cfg, brain, dest)?;
10111 let current = v2_pull_baseline_coordinate(baseline.as_ref());
10112 if current == journal.next {
10113 cleanup_v2_pull_journal(&store, &journal)?;
10114 } else {
10115 if current != journal.previous {
10116 return Err(invalid_feed(
10117 "cannot recover interrupted pull because its baseline changed afterward",
10118 ));
10119 }
10120 rollback_v2_pull(&store, &journal)?;
10121 }
10122 }
10123 }
10124 prune_orphan_v2_pull_backups(&store)
10129}
10130
10131fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
10132 let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
10133 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
10134 })?;
10135 if let Some(journal) = load_v2_pull_journal(&store)? {
10136 cleanup_v2_pull_journal(&store, &journal)?;
10137 }
10138 Ok(())
10139}
10140
10141#[cfg(windows)]
10142fn install_windows_initial_sources(
10143 dest: &Path,
10144 entries: &[V2StagedFile],
10145 rebuild_indexes: bool,
10146) -> LinkResult<()> {
10147 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10148 let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
10149 path: dest.display().to_string(),
10150 })?;
10151 let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
10152 if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
10153 return Err(LinkError::UnsafePath {
10154 path: dest.display().to_string(),
10155 });
10156 }
10157 let stage_name = format!(
10158 ".{}.dbmd-pull-stage-{}",
10159 name.to_string_lossy(),
10160 crate::ulid::mint()
10161 );
10162 let stage_path = parent.join(&stage_name);
10163 let stage_capability =
10164 crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
10165 let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
10166 let prepared = (|| -> LinkResult<()> {
10167 for entry in entries {
10168 let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
10169 if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
10170 return Err(invalid_feed(
10171 "private staged sync source failed final integrity verification",
10172 ));
10173 }
10174 stage.write_atomic(Path::new(&entry.path), &bytes)?;
10175 }
10176 let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
10177 .map_err(|error| LinkError::InvalidPack {
10178 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10179 })?;
10180 if rebuild_indexes {
10181 crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
10182 message: format!("could not materialize v2 local catalogs: {error}"),
10183 })?;
10184 }
10185 Ok(())
10186 })();
10187 if let Err(error) = prepared {
10188 let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
10189 return Err(error);
10190 }
10191 crate::fsx::rename_directory_beneath(
10192 &parent_capability,
10193 Path::new(&stage_name),
10194 Path::new(name),
10195 )?;
10196 Ok(())
10197}
10198
10199fn install_established_v2_delta(
10200 store: Store,
10201 entries: &[V2StagedFile],
10202 deleted: &[String],
10203 rebuild_indexes: bool,
10204 previous: Option<&V2SyncBaseline>,
10205 next: &V2VerifiedHead,
10206) -> LinkResult<()> {
10207 if load_v2_pull_journal(&store)?.is_some() {
10208 return Err(invalid_feed(
10209 "an interrupted pull must be recovered before installing",
10210 ));
10211 }
10212 let mut sources = std::collections::BTreeMap::new();
10213 for entry in entries {
10214 if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
10215 return Err(invalid_feed("pull mutation repeats a path"));
10216 }
10217 }
10218 let mut paths = sources.keys().cloned().collect::<Vec<_>>();
10219 paths.extend(deleted.iter().cloned());
10220 paths.sort();
10221 paths.dedup();
10222 if paths.is_empty() {
10223 return Ok(());
10224 }
10225 let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
10226 let mut journal = V2PullJournal {
10227 v: 1,
10228 phase: V2PullPhase::Preparing,
10229 brain: next.brain_id.clone(),
10230 previous: v2_pull_baseline_coordinate(previous),
10231 next: v2_pull_head_coordinate(next),
10232 backup_dir: backup_dir.clone(),
10233 entries: Vec::with_capacity(paths.len()),
10234 };
10235 for path in &paths {
10236 let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
10237 let new = sources.get(path).map(|entry| V2PullFileCoordinate {
10238 sha256: entry.sha256.clone(),
10239 bytes: entry.bytes,
10240 });
10241 if old == new {
10242 continue;
10243 }
10244 let index = journal.entries.len();
10245 journal.entries.push(V2PullJournalEntry {
10246 path: path.clone(),
10247 backup: old.as_ref().map(|_| format!("{index:08x}")),
10248 old,
10249 new,
10250 });
10251 }
10252 if journal.entries.is_empty() {
10253 return Ok(());
10254 }
10255 let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
10256 entry
10257 .old
10258 .as_ref()
10259 .map_or(Some(total), |old| total.checked_add(old.bytes))
10260 });
10261 if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
10262 return Err(LinkError::InvalidPack {
10263 message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
10264 });
10265 }
10266 validate_v2_pull_journal(&journal)?;
10267 store.write_private_atomic_new(
10268 Path::new(V2_PULL_JOURNAL),
10269 &v2_pull_journal_bytes(&journal)?,
10270 )?;
10271 let prepared = (|| -> LinkResult<()> {
10272 store.create_private_dir_all(Path::new(&backup_dir))?;
10273 for entry in &journal.entries {
10274 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10275 let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
10276 if content_sha256(&bytes) != old.sha256 {
10277 return Err(invalid_feed("live pull source changed during backup"));
10278 }
10279 store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
10280 }
10281 }
10282 journal.phase = V2PullPhase::Ready;
10283 store.write_private_atomic(
10284 Path::new(V2_PULL_JOURNAL),
10285 &v2_pull_journal_bytes(&journal)?,
10286 )?;
10287 Ok(())
10288 })();
10289 if let Err(error) = prepared {
10290 let cleanup = cleanup_v2_pull_journal(&store, &journal);
10291 return match cleanup {
10292 Ok(()) => Err(error),
10293 Err(cleanup) => Err(LinkError::InvalidPack {
10294 message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
10295 }),
10296 };
10297 }
10298 let installed = (|| -> LinkResult<()> {
10299 for entry in &journal.entries {
10300 if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
10301 return Err(LinkError::InvalidPack {
10302 message: format!("local path `{}` changed during pull", entry.path),
10303 });
10304 }
10305 if let Some(source) = sources.get(&entry.path) {
10306 let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
10307 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
10308 return Err(invalid_feed(
10309 "private staged sync source failed final integrity verification",
10310 ));
10311 }
10312 store.write_atomic(Path::new(&entry.path), &bytes)?;
10313 } else if entry.old.is_some() {
10314 store.remove_file(Path::new(&entry.path))?;
10315 }
10316 }
10317 if rebuild_indexes {
10318 crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
10319 message: format!("could not materialize v2 local catalogs: {error}"),
10320 })?;
10321 }
10322 Ok(())
10323 })();
10324 if let Err(error) = installed {
10325 return match rollback_v2_pull(&store, &journal) {
10326 Ok(()) => Err(error),
10327 Err(rollback) => Err(LinkError::InvalidPack {
10328 message: format!("{error}; durable pull rollback also failed: {rollback}"),
10329 }),
10330 };
10331 }
10332 Ok(())
10333}
10334
10335#[cfg(windows)]
10336fn install_pulled_delta_sources(
10337 dest: &Path,
10338 entries: &[V2StagedFile],
10339 deleted: &[String],
10340 rebuild_indexes: bool,
10341 previous: Option<&V2SyncBaseline>,
10342 next: &V2VerifiedHead,
10343) -> LinkResult<()> {
10344 match Store::open_strict(dest) {
10345 Ok(store) => {
10346 install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
10347 }
10348 Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
10349 }
10350}
10351
10352#[cfg(not(any(unix, windows)))]
10353fn install_pulled_delta_sources(
10354 _dest: &Path,
10355 _entries: &[V2StagedFile],
10356 _deleted: &[String],
10357 _rebuild_indexes: bool,
10358 _previous: Option<&V2SyncBaseline>,
10359 _next: &V2VerifiedHead,
10360) -> LinkResult<()> {
10361 Err(LinkError::UnsupportedPlatform {
10362 operation: "atomic v2 pull install",
10363 })
10364}
10365
10366#[cfg(unix)]
10367fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
10368 install_pulled_delta(dest, entries, &[], false)
10369}
10370
10371#[cfg(not(windows))]
10372fn is_safe_slug(slug: &str) -> bool {
10373 !slug.is_empty()
10374 && slug.len() <= 63
10375 && !slug.starts_with('-')
10376 && !slug.ends_with('-')
10377 && slug
10378 .bytes()
10379 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
10380}
10381
10382fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
10383 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
10384}
10385
10386fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
10387 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
10388}
10389
10390fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
10391 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
10392}
10393
10394fn preflight_zip_central_directory(
10395 bytes: &[u8],
10396 offset: usize,
10397 size: usize,
10398 count: u64,
10399) -> LinkResult<()> {
10400 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
10401 let end = offset
10402 .checked_add(size)
10403 .filter(|end| *end <= bytes.len())
10404 .ok_or_else(|| LinkError::InvalidPack {
10405 message: "ZIP central directory is out of bounds".to_string(),
10406 })?;
10407 let mut cursor = offset;
10408 for _ in 0..count {
10409 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
10410 return Err(LinkError::InvalidPack {
10411 message: "ZIP central directory entry count is inconsistent".to_string(),
10412 });
10413 }
10414 if le_u16(bytes, cursor + 34) != Some(0) {
10415 return Err(LinkError::InvalidPack {
10416 message: "multi-disk ZIP archives are not supported".to_string(),
10417 });
10418 }
10419 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
10420 total.checked_add(le_u16(bytes, cursor + at)? as usize)
10421 });
10422 cursor = cursor
10423 .checked_add(46)
10424 .and_then(|fixed| fixed.checked_add(variable?))
10425 .filter(|cursor| *cursor <= end)
10426 .ok_or_else(|| LinkError::InvalidPack {
10427 message: "ZIP central directory entry is truncated".to_string(),
10428 })?;
10429 }
10430 if cursor != end {
10431 return Err(LinkError::InvalidPack {
10432 message: "ZIP central directory size is inconsistent".to_string(),
10433 });
10434 }
10435 Ok(())
10436}
10437
10438fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
10442 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
10443 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
10444 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
10445 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
10446 let eocd = bytes[search_start..]
10447 .windows(4)
10448 .rposition(|window| window == EOCD_SIG)
10449 .map(|offset| search_start + offset)
10450 .ok_or_else(|| LinkError::InvalidPack {
10451 message: "ZIP has no end-of-central-directory record".to_string(),
10452 })?;
10453 let invalid_end = || LinkError::InvalidPack {
10454 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
10455 };
10456 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
10457 if eocd
10458 .checked_add(22)
10459 .and_then(|end| end.checked_add(comment_len))
10460 != Some(bytes.len())
10461 {
10462 return Err(invalid_end());
10466 }
10467 let disk = le_u16(bytes, eocd + 4);
10468 let central_disk = le_u16(bytes, eocd + 6);
10469 if disk != Some(0) || central_disk != Some(0) {
10470 return Err(LinkError::InvalidPack {
10471 message: "multi-disk ZIP archives are not supported".to_string(),
10472 });
10473 }
10474 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
10475 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
10476 if entries_on_disk != ordinary {
10477 return Err(LinkError::InvalidPack {
10478 message: "multi-disk ZIP archives are not supported".to_string(),
10479 });
10480 }
10481 let zip64_locator = eocd
10482 .checked_sub(20)
10483 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
10484 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
10485 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
10486 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
10487 if central_offset
10488 .checked_add(central_size)
10489 .filter(|end| *end == eocd)
10490 .is_none()
10491 {
10492 return Err(invalid_end());
10493 }
10494 (ordinary as u64, central_offset, central_size)
10495 } else {
10496 let Some(locator) = zip64_locator else {
10497 return Err(invalid_end());
10498 };
10499 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
10500 return Err(LinkError::InvalidPack {
10501 message: "multi-disk ZIP64 archives are not supported".to_string(),
10502 });
10503 }
10504 let record = le_u64(bytes, locator + 8)
10505 .and_then(|offset| usize::try_from(offset).ok())
10506 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
10507 .ok_or_else(|| LinkError::InvalidPack {
10508 message: "ZIP64 archive has an invalid end record".to_string(),
10509 })?;
10510 let record_size = le_u64(bytes, record + 4)
10511 .and_then(|size| usize::try_from(size).ok())
10512 .filter(|size| *size >= 44)
10513 .ok_or_else(invalid_end)?;
10514 if record
10515 .checked_add(12)
10516 .and_then(|end| end.checked_add(record_size))
10517 != Some(locator)
10518 || le_u32(bytes, record + 16) != Some(0)
10519 || le_u32(bytes, record + 20) != Some(0)
10520 {
10521 return Err(invalid_end());
10522 }
10523 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
10524 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
10525 let central_size = le_u64(bytes, record + 40)
10526 .and_then(|size| usize::try_from(size).ok())
10527 .ok_or_else(invalid_end)?;
10528 let central_offset = le_u64(bytes, record + 48)
10529 .and_then(|offset| usize::try_from(offset).ok())
10530 .ok_or_else(invalid_end)?;
10531 if zip64_on_disk != zip64_total
10532 || central_offset
10533 .checked_add(central_size)
10534 .filter(|end| *end == record)
10535 .is_none()
10536 {
10537 return Err(invalid_end());
10538 }
10539 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10540 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10541 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10542 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10543 {
10544 return Err(invalid_end());
10545 }
10546 (zip64_total, central_offset, central_size)
10547 };
10548 if count == 0 || count > max_entries as u64 {
10549 return Err(LinkError::InvalidPack {
10550 message: format!("invalid file count {count}"),
10551 });
10552 }
10553 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10554 Ok(())
10555}
10556
10557fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10558 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10559 let mut archive =
10560 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10561 message: format!("ZIP parse failed: {err}"),
10562 })?;
10563 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10564 return Err(LinkError::InvalidPack {
10565 message: format!("invalid file count {}", archive.len()),
10566 });
10567 }
10568 let mut total = 0u64;
10569 let mut seen = std::collections::HashSet::new();
10570 let mut entries = Vec::with_capacity(archive.len());
10571 for index in 0..archive.len() {
10572 let mut file = archive
10573 .by_index(index)
10574 .map_err(|err| LinkError::InvalidPack {
10575 message: format!("ZIP entry failed: {err}"),
10576 })?;
10577 if file.is_dir() {
10578 continue;
10579 }
10580 let path = file.name().to_string();
10581 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10582 return Err(LinkError::UnsafePath { path });
10583 }
10584 if file
10585 .unix_mode()
10586 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
10587 {
10588 return Err(LinkError::InvalidPack {
10589 message: format!("non-file entry `{path}`"),
10590 });
10591 }
10592 if !seen.insert(path.clone()) {
10593 return Err(LinkError::InvalidPack {
10594 message: format!("duplicate path `{path}`"),
10595 });
10596 }
10597 let remaining = MAX_STORE_BYTES.saturating_sub(total);
10598 if file.size() > remaining {
10599 return Err(LinkError::InvalidPack {
10600 message: "expanded content exceeds the 512 MB limit".to_string(),
10601 });
10602 }
10603 let mut content = Vec::new();
10604 (&mut file)
10605 .take(remaining + 1)
10606 .read_to_end(&mut content)
10607 .map_err(|err| LinkError::InvalidPack {
10608 message: format!("could not decompress `{path}`: {err}"),
10609 })?;
10610 if content.len() as u64 > remaining {
10611 return Err(LinkError::InvalidPack {
10612 message: "expanded content exceeds the 512 MB limit".to_string(),
10613 });
10614 }
10615 if content.len() as u64 != file.size() {
10616 return Err(LinkError::InvalidPack {
10617 message: format!("length mismatch for `{path}`"),
10618 });
10619 }
10620 total += content.len() as u64;
10621 entries.push((path, content));
10622 }
10623 if entries.is_empty() {
10624 return Err(LinkError::InvalidPack {
10625 message: "pack contains no files".to_string(),
10626 });
10627 }
10628 Ok(entries)
10629}
10630
10631fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
10632 let mut expected = std::collections::BTreeMap::new();
10633 for file in signed {
10634 if !safe_store_rel_path(&file.path) {
10635 return Err(LinkError::UnsafePath {
10636 path: file.path.clone(),
10637 });
10638 }
10639 if !is_sha256(&file.sha256)
10640 || expected
10641 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10642 .is_some()
10643 {
10644 return Err(invalid_feed(
10645 "signed snapshot manifest contains an invalid or duplicate file",
10646 ));
10647 }
10648 }
10649 if expected.len() != entries.len() {
10650 return Err(invalid_feed(
10651 "downloaded pack file set differs from the signed snapshot manifest",
10652 ));
10653 }
10654 for (path, bytes) in entries {
10655 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
10656 return Err(invalid_feed(format!(
10657 "downloaded pack contains unsigned path `{path}`"
10658 )));
10659 };
10660 if *declared_bytes != bytes.len() as u64
10661 || *sha256 != format!("{:x}", Sha256::digest(bytes))
10662 {
10663 return Err(invalid_feed(format!(
10664 "downloaded file `{path}` differs from its signed manifest"
10665 )));
10666 }
10667 }
10668 Ok(())
10669}
10670
10671pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
10678 require_hardened_filesystem("sync push")?;
10679 preflight_push_ownership(store)?;
10680 let mut out: Vec<(String, String)> = Vec::new();
10681 let mut total = 0u64;
10682
10683 let mut read_text = |rel: &str| -> LinkResult<String> {
10684 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
10685 total = total
10686 .checked_add(bytes.len() as u64)
10687 .ok_or_else(|| LinkError::PushTooLarge {
10688 detail: "uncompressed byte count overflow".to_string(),
10689 })?;
10690 if total > MAX_STORE_BYTES {
10691 return Err(LinkError::PushTooLarge {
10692 detail: format!("{total} uncompressed bytes"),
10693 });
10694 }
10695 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
10696 path: rel.to_string(),
10697 })
10698 };
10699
10700 out.push(("DB.md".to_string(), read_text("DB.md")?));
10701 if store
10702 .regular_file_exists(Path::new("assets.jsonl"))
10703 .unwrap_or(false)
10704 {
10705 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
10706 }
10707
10708 for rel in store.walk()? {
10709 let rel_str = rel.to_string_lossy().replace('\\', "/");
10710 if !safe_store_rel_path(&rel_str) {
10711 return Err(LinkError::UnsafePath { path: rel_str });
10714 }
10715 let content = read_text(&rel_str)?;
10716 out.push((rel_str, content));
10717 }
10718
10719 out.sort_by(|a, b| a.0.cmp(&b.0));
10720 Ok(out)
10721}
10722
10723fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
10727 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
10728 return Err(LinkError::from(std::io::Error::new(
10729 std::io::ErrorKind::PermissionDenied,
10730 format!("cannot push: nested db.md store at {}", nested.display()),
10731 )));
10732 }
10733
10734 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
10735 return Err(LinkError::from(std::io::Error::new(
10736 std::io::ErrorKind::PermissionDenied,
10737 format!(
10738 "cannot push: {} is a symlink outside the store ownership model",
10739 symlink.display()
10740 ),
10741 )));
10742 }
10743 Ok(())
10744}
10745
10746pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
10752 require_safe_ref(brain)?;
10753 let remote = verified_remote_head(cfg, brain, false)?;
10754 if files.len() > MAX_PUSH_FILES {
10755 return Err(LinkError::PushTooLarge {
10756 detail: format!("{} files", files.len()),
10757 });
10758 }
10759 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
10760 if raw_total > MAX_STORE_BYTES {
10761 return Err(LinkError::PushTooLarge {
10762 detail: format!("{raw_total} uncompressed bytes"),
10763 });
10764 }
10765
10766 if cfg.brain_key.is_none() {
10770 let body = json!({
10771 "files": files
10772 .iter()
10773 .map(|(p, c)| json!({ "path": p, "content": c }))
10774 .collect::<Vec<_>>(),
10775 });
10776 if body.to_string().len() <= MAX_PUSH_BYTES {
10777 let path = format!("/api/hub/brains/{brain}/push");
10778 let pushed = ensure_ok(
10779 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10780 "sync push",
10781 )?;
10782 return Ok(pushed);
10783 }
10784 }
10785
10786 let pack = build_store_pack(files)?;
10787 if pack.len() as u64 > MAX_PACK_BYTES {
10788 return Err(LinkError::PushTooLarge {
10789 detail: format!("{} pack bytes", pack.len()),
10790 });
10791 }
10792 let sha256 = format!("{:x}", Sha256::digest(&pack));
10793 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
10794 if let Some(key) = &cfg.brain_key {
10795 if !remote.head.verified {
10796 return Err(invalid_feed(
10797 "self-custody push requires a fully verified, unscoped feed head",
10798 ));
10799 }
10800 let identity = remote
10801 .identity
10802 .as_ref()
10803 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
10804 let current_multikey = format!("ed25519:{}", identity.fingerprint);
10805 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
10806 return Err(invalid_feed(
10807 "configured brain key is not the verified current brain identity",
10808 ));
10809 }
10810 let next_seq = remote
10813 .head
10814 .seq
10815 .checked_add(1)
10816 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
10817 let mut manifest: Vec<WireFeedFile> = files
10818 .iter()
10819 .map(|(path, content)| WireFeedFile {
10820 path: path.clone(),
10821 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
10822 bytes: content.len() as u64,
10823 })
10824 .collect();
10825 manifest.sort_by(|a, b| a.path.cmp(&b.path));
10826 let ts = crate::now()
10827 .with_timezone(&chrono::Utc)
10828 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
10829 .to_string();
10830 let entry = self_custody_entry(
10831 key,
10832 next_seq,
10833 ts,
10834 &sha256,
10835 &manifest,
10836 remote.head.feed_hash.as_deref(),
10837 )?;
10838 meta["entry"] = Value::String(entry);
10839 }
10840 let presigned = ensure_ok(
10841 request(
10842 cfg,
10843 "POST",
10844 &format!("/api/hub/brains/{brain}/packs/presign"),
10845 Some(&meta),
10846 Auth::Required,
10847 )?,
10848 "prepare pack upload",
10849 )?;
10850 let url = presigned
10851 .get("url")
10852 .and_then(Value::as_str)
10853 .ok_or_else(|| LinkError::InvalidPack {
10854 message: "the hub returned no upload URL".to_string(),
10855 })?;
10856 put_presigned(
10857 cfg,
10858 url,
10859 presigned.get("headers").unwrap_or(&Value::Null),
10860 &pack,
10861 )?;
10862 let committed = ensure_ok(
10863 request(
10864 cfg,
10865 "POST",
10866 &format!("/api/hub/brains/{brain}/packs/commit"),
10867 Some(&meta),
10868 Auth::Required,
10869 )?,
10870 "commit pack",
10871 )?;
10872 Ok(committed)
10873}
10874
10875fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
10876 const LOCAL_HEADER: u32 = 0x0403_4b50;
10877 const CENTRAL_HEADER: u32 = 0x0201_4b50;
10878 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
10879 const VERSION_20: u16 = 20;
10880 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
10881 const UTF8_FLAG: u16 = 1 << 11;
10882 const STORED: u16 = 0;
10883 const DOS_TIME_MIDNIGHT: u16 = 0;
10884 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
10885 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
10886
10887 struct CentralEntry<'a> {
10888 name: &'a [u8],
10889 crc32: u32,
10890 size: u32,
10891 local_offset: u32,
10892 }
10893
10894 fn push_u16(out: &mut Vec<u8>, value: u16) {
10895 out.extend_from_slice(&value.to_le_bytes());
10896 }
10897
10898 fn push_u32(out: &mut Vec<u8>, value: u32) {
10899 out.extend_from_slice(&value.to_le_bytes());
10900 }
10901
10902 if files.is_empty() {
10903 return Err(LinkError::InvalidPack {
10904 message: "cannot create an empty snapshot pack".to_string(),
10905 });
10906 }
10907 if files.len() > u16::MAX as usize {
10908 return Err(LinkError::PushTooLarge {
10909 detail: format!(
10910 "{} files (canonical ZIP32 packs cap at {})",
10911 files.len(),
10912 u16::MAX
10913 ),
10914 });
10915 }
10916
10917 let mut sorted: Vec<_> = files.iter().collect();
10918 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
10919 let mut previous: Option<&str> = None;
10920 for (path, content) in &sorted {
10921 if !safe_store_rel_path(path) {
10922 return Err(LinkError::UnsafePath {
10923 path: (*path).clone(),
10924 });
10925 }
10926 if previous == Some(path.as_str()) {
10927 return Err(LinkError::InvalidPack {
10928 message: format!("duplicate path `{path}`"),
10929 });
10930 }
10931 previous = Some(path.as_str());
10932 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
10933 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
10934 })?;
10935 }
10936
10937 let mut out = Vec::new();
10938 let mut central = Vec::with_capacity(sorted.len());
10939 for (path, content) in sorted {
10940 let name = path.as_bytes();
10941 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
10942 message: format!("ZIP entry name is too long: `{path}`"),
10943 })?;
10944 let bytes = content.as_bytes();
10945 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
10946 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
10947 })?;
10948 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
10949 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
10950 })?;
10951 let crc32 = crc32fast::hash(bytes);
10952
10953 push_u32(&mut out, LOCAL_HEADER);
10956 push_u16(&mut out, VERSION_20);
10957 push_u16(&mut out, UTF8_FLAG);
10958 push_u16(&mut out, STORED);
10959 push_u16(&mut out, DOS_TIME_MIDNIGHT);
10960 push_u16(&mut out, DOS_DATE_1980_01_01);
10961 push_u32(&mut out, crc32);
10962 push_u32(&mut out, size);
10963 push_u32(&mut out, size);
10964 push_u16(&mut out, name_len);
10965 push_u16(&mut out, 0); out.extend_from_slice(name);
10967 out.extend_from_slice(bytes);
10968
10969 central.push(CentralEntry {
10970 name,
10971 crc32,
10972 size,
10973 local_offset,
10974 });
10975 }
10976
10977 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
10978 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
10979 })?;
10980 for entry in ¢ral {
10981 push_u32(&mut out, CENTRAL_HEADER);
10982 push_u16(&mut out, MADE_BY_UNIX_20);
10983 push_u16(&mut out, VERSION_20);
10984 push_u16(&mut out, UTF8_FLAG);
10985 push_u16(&mut out, STORED);
10986 push_u16(&mut out, DOS_TIME_MIDNIGHT);
10987 push_u16(&mut out, DOS_DATE_1980_01_01);
10988 push_u32(&mut out, entry.crc32);
10989 push_u32(&mut out, entry.size);
10990 push_u32(&mut out, entry.size);
10991 push_u16(&mut out, entry.name.len() as u16);
10992 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);
10997 push_u32(&mut out, entry.local_offset);
10998 out.extend_from_slice(entry.name);
10999 }
11000 let central_size = u32::try_from(out.len())
11001 .ok()
11002 .and_then(|end| end.checked_sub(central_offset))
11003 .ok_or_else(|| LinkError::PushTooLarge {
11004 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
11005 })?;
11006 let entry_count = central.len() as u16;
11007
11008 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
11009 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
11012 push_u16(&mut out, entry_count);
11013 push_u32(&mut out, central_size);
11014 push_u32(&mut out, central_offset);
11015 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
11018 return Err(LinkError::PushTooLarge {
11019 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
11020 });
11021 }
11022 Ok(out)
11023}
11024
11025#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11031pub enum Capability {
11032 Read,
11034 Write,
11036}
11037
11038impl Capability {
11039 pub fn as_str(self) -> &'static str {
11041 match self {
11042 Capability::Read => "read",
11043 Capability::Write => "write",
11044 }
11045 }
11046}
11047
11048pub fn grant_issue(
11054 cfg: &HubConfig,
11055 brain: &str,
11056 grantee: &str,
11057 can: Capability,
11058 scope: Option<&str>,
11059 until: Option<&str>,
11060) -> LinkResult<Value> {
11061 require_safe_ref(brain)?;
11062 let is_key_grantee = URL_SAFE_NO_PAD
11067 .decode(grantee)
11068 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
11069 .unwrap_or(false);
11070 if let Some(head) = v2_verified_head(cfg, brain)? {
11071 if is_key_grantee {
11072 let scope = scope.unwrap_or("");
11073 let preset = match can {
11074 Capability::Read => "viewer",
11075 Capability::Write => "editor",
11076 };
11077 let entropy = format!(
11078 "{}\0{}\0{}\0{}\0{}\0{}\0{}",
11079 normalized_origin(&cfg.hub)?,
11080 head.brain_id,
11081 head.control_revision,
11082 grantee,
11083 preset,
11084 scope,
11085 until.unwrap_or("")
11086 );
11087 let mut body = json!({
11088 "context": "external",
11089 "expected_control_revision": head.control_revision,
11090 "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
11091 "preset": preset,
11092 "principal_kind": "key",
11093 "public_key": grantee,
11094 "scope": scope,
11095 "scope_kind": "prefix",
11096 });
11097 if let Some(value) = until {
11098 body["expires_at"] = json!(value);
11099 }
11100 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11101 let response = ensure_ok(
11102 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11103 "v2 grant issue",
11104 )?;
11105 let expected_fingerprint = identity_fingerprint(grantee)?;
11106 if response.get("v").and_then(Value::as_u64) != Some(2)
11107 || response
11108 .get("id")
11109 .and_then(Value::as_str)
11110 .is_none_or(|id| !crate::ulid::is_ulid(id))
11111 || response.get("principal_kind").and_then(Value::as_str) != Some("key")
11112 || response.get("principal_id").and_then(Value::as_str)
11113 != Some(expected_fingerprint.as_str())
11114 || response
11115 .get("control_revision")
11116 .and_then(Value::as_str)
11117 .is_none_or(|value| !is_sha256(value))
11118 {
11119 return Err(invalid_feed(
11120 "v2 grant issue response is not authority-bound",
11121 ));
11122 }
11123 return Ok(response);
11124 }
11125 let mut body = json!({ "email": grantee, "capability": can.as_str() });
11131 if let Some(value) = scope {
11132 body["scopePrefix"] = json!(value);
11133 }
11134 if let Some(value) = until {
11135 body["expiresAt"] = json!(value);
11136 }
11137 let path = format!("/api/hub/brains/{}/grants", head.brain_id);
11138 return ensure_ok(
11139 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11140 "account grant issue",
11141 );
11142 }
11143 let _ = verified_remote_head(cfg, brain, false)?;
11144 let mut body = if is_key_grantee {
11145 json!({ "keySpki": grantee, "capability": can.as_str() })
11146 } else {
11147 json!({ "email": grantee, "capability": can.as_str() })
11148 };
11149 if let Some(s) = scope {
11150 body["scopePrefix"] = json!(s);
11151 }
11152 if let Some(u) = until {
11153 body["expiresAt"] = json!(u);
11154 }
11155 let path = format!("/api/hub/brains/{brain}/grants");
11156 ensure_ok(
11157 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11158 "grant issue",
11159 )
11160}
11161
11162pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
11164 require_safe_ref(brain)?;
11165 if let Some(head) = v2_verified_head(cfg, brain)? {
11166 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11167 let response = ensure_ok(
11168 request(cfg, "GET", &path, None, Auth::Required)?,
11169 "v2 grant list",
11170 )?;
11171 if response.get("v").and_then(Value::as_u64) != Some(2)
11172 || response.get("control_revision").and_then(Value::as_str)
11173 != Some(head.control_revision.as_str())
11174 || !response.get("grants").is_some_and(Value::is_array)
11175 {
11176 return Err(invalid_feed(
11177 "v2 grant list is not bound to the verified authority",
11178 ));
11179 }
11180 return Ok(response);
11181 }
11182 let _ = verified_remote_head(cfg, brain, false)?;
11183 let path = format!("/api/hub/brains/{brain}/grants");
11184 ensure_ok(
11185 request(cfg, "GET", &path, None, Auth::Required)?,
11186 "grant list",
11187 )
11188}
11189
11190pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
11193 require_safe_ref(brain)?;
11194 require_safe_grant_id(grant_id)?;
11195 if let Some(head) = v2_verified_head(cfg, brain)? {
11196 let entropy = format!(
11197 "{}\0{}\0{}\0{}",
11198 normalized_origin(&cfg.hub)?,
11199 head.brain_id,
11200 head.control_revision,
11201 grant_id
11202 );
11203 let body = json!({
11204 "expected_control_revision": head.control_revision,
11205 "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
11206 });
11207 let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
11208 let response = ensure_ok(
11209 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11210 "v2 grant revoke",
11211 )?;
11212 if response.get("v").and_then(Value::as_u64) != Some(2)
11213 || response.get("id").and_then(Value::as_str) != Some(grant_id)
11214 || response.get("revoked").and_then(Value::as_bool) != Some(true)
11215 || response
11216 .get("control_revision")
11217 .and_then(Value::as_str)
11218 .is_none_or(|value| !is_sha256(value))
11219 {
11220 return Err(invalid_feed(
11221 "v2 grant revocation response is not authority-bound",
11222 ));
11223 }
11224 return Ok(response);
11225 }
11226 let _ = verified_remote_head(cfg, brain, false)?;
11227 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
11228 ensure_ok(
11229 request(cfg, "DELETE", &path, None, Auth::Required)?,
11230 "grant revoke",
11231 )
11232}
11233
11234#[derive(Debug)]
11239struct VerifiedV2Proposal {
11240 value: Value,
11241 changes: Value,
11242 blobs: Vec<(String, u64, String)>,
11243}
11244
11245fn require_proposal_id(id: &str) -> LinkResult<()> {
11246 if crate::ulid::is_ulid(id) {
11247 Ok(())
11248 } else {
11249 Err(invalid_feed("proposal id is not a lowercase ULID"))
11250 }
11251}
11252
11253fn verified_v2_proposal(
11254 cfg: &HubConfig,
11255 head: &V2VerifiedHead,
11256 proposal_id: &str,
11257) -> LinkResult<VerifiedV2Proposal> {
11258 require_proposal_id(proposal_id)?;
11259 if head.view_kind != "full" {
11260 return Err(invalid_feed(
11261 "proposal review requires a full readable view",
11262 ));
11263 }
11264 let path = format!(
11265 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11266 head.brain_id
11267 );
11268 let value = ensure_ok(
11269 request_capped(
11270 cfg,
11271 "GET",
11272 &path,
11273 None,
11274 Auth::Required,
11275 MAX_FEED_RESPONSE_BYTES,
11276 )?,
11277 "v2 proposal",
11278 )?;
11279 verify_v2_proposal_value(head, proposal_id, value)
11280}
11281
11282fn verify_v2_proposal_value(
11283 head: &V2VerifiedHead,
11284 proposal_id: &str,
11285 value: Value,
11286) -> LinkResult<VerifiedV2Proposal> {
11287 if value.get("v").and_then(Value::as_u64) != Some(2) {
11288 return Err(invalid_feed("proposal response has an invalid version"));
11289 }
11290 let proposal = value
11291 .get("proposal")
11292 .and_then(Value::as_object)
11293 .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
11294 if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
11295 return Err(invalid_feed("proposal response changed its id"));
11296 }
11297 let payload_hash = proposal
11298 .get("payload_sha256")
11299 .and_then(Value::as_str)
11300 .filter(|hash| is_sha256(hash))
11301 .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
11302 let clear_hash = proposal
11303 .get("clear_sha256")
11304 .and_then(Value::as_str)
11305 .filter(|hash| is_sha256(hash))
11306 .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
11307 let submission_hash = proposal
11308 .get("submission_claim_sha256")
11309 .and_then(Value::as_str)
11310 .filter(|hash| is_sha256(hash))
11311 .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
11312 let submission = STANDARD
11313 .decode(
11314 proposal
11315 .get("submission_claim_base64")
11316 .and_then(Value::as_str)
11317 .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
11318 )
11319 .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
11320 let submission_value: Value = serde_json::from_slice(&submission)
11321 .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
11322 if crate::linkmd_v2::canonical_bytes(&submission_value)
11323 .map_err(|error| invalid_feed(error.to_string()))?
11324 != submission
11325 || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
11326 .map_err(|error| invalid_feed(error.to_string()))?
11327 != submission_hash
11328 {
11329 return Err(invalid_feed(
11330 "proposal submission claim is not canonical or addressed",
11331 ));
11332 }
11333 let envelope = submission_value
11334 .as_object()
11335 .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
11336 let claim = envelope
11337 .get("claim")
11338 .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
11339 let claim_object = claim
11340 .as_object()
11341 .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
11342 let actor_root = claim_object
11343 .get("actor_root")
11344 .and_then(Value::as_object)
11345 .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
11346 let public_key = envelope
11347 .get("public_key")
11348 .and_then(Value::as_str)
11349 .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
11350 let fingerprint = envelope
11351 .get("fingerprint")
11352 .and_then(Value::as_str)
11353 .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
11354 let signature = envelope
11355 .get("sig")
11356 .and_then(Value::as_str)
11357 .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
11358 let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
11359 .map_err(|error| invalid_feed(error.to_string()))?;
11360 let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
11361 let signer = format!("{fingerprint}:{public_key}");
11362 let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
11363 let grants = actor_root.get("grants").and_then(Value::as_array);
11364 let grants_are_canonical = grants.is_some_and(|items| {
11365 let mut prior: Option<&str> = None;
11366 items.iter().all(|item| {
11367 let Some(grant) = item.as_str() else {
11368 return false;
11369 };
11370 if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
11371 return false;
11372 }
11373 prior = Some(grant);
11374 true
11375 })
11376 });
11377 let optional_actor_field = |name: &str| {
11378 actor_root.get(name).is_some_and(|value| {
11379 value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
11380 })
11381 };
11382 let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
11383 if claim_object.get("v").and_then(Value::as_u64) != Some(2)
11384 || format!("{:x}", Sha256::digest(&der)) != fingerprint
11385 || head
11386 .trust
11387 .hub_signer
11388 .as_ref()
11389 .is_some_and(|known| known != &signer)
11390 || !matches!(
11391 actor_class,
11392 Some(
11393 "user"
11394 | "owned_agent"
11395 | "foreign_key"
11396 | "curation"
11397 | "inbox"
11398 | "restore"
11399 | "migration"
11400 | "operator_recovery"
11401 )
11402 )
11403 || actor_root
11404 .get("principal")
11405 .and_then(Value::as_str)
11406 .is_none_or(|value| value.is_empty())
11407 || actor_root
11408 .get("credential")
11409 .and_then(Value::as_str)
11410 .is_none_or(|value| value.is_empty())
11411 || !optional_actor_field("organization")
11412 || !optional_actor_field("role")
11413 || !grants_are_canonical
11414 || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
11415 || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11416 || !claim_object
11417 .get("mutation_id")
11418 .and_then(Value::as_str)
11419 .is_some_and(|value| {
11420 !value.is_empty()
11421 && value.len() <= 128
11422 && value.chars().enumerate().all(|(index, char)| {
11423 char.is_ascii_alphanumeric()
11424 || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
11425 })
11426 })
11427 || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
11428 || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
11429 || !claim_object
11430 .get("control_revision")
11431 .and_then(Value::as_str)
11432 .is_some_and(is_sha256)
11433 || submitted_at.is_none_or(|value| {
11434 chrono::DateTime::parse_from_rfc3339(value).is_err()
11435 || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
11436 })
11437 || !proposal
11438 .get("state")
11439 .and_then(Value::as_str)
11440 .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
11441 || proposal
11442 .get("expires_at")
11443 .and_then(Value::as_str)
11444 .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
11445 || proposal
11446 .get("proposer")
11447 .and_then(Value::as_object)
11448 .and_then(|value| value.get("class"))
11449 .and_then(Value::as_str)
11450 != actor_class
11451 {
11452 return Err(invalid_feed(
11453 "proposal submission claim does not bind the verified proposal",
11454 ));
11455 }
11456 let changes_b64 = proposal
11457 .get("changes_base64")
11458 .and_then(Value::as_str)
11459 .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
11460 let changes_bytes = STANDARD
11461 .decode(changes_b64)
11462 .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
11463 let changes: Value = serde_json::from_slice(&changes_bytes)
11464 .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
11465 if crate::linkmd_v2::canonical_bytes(&changes)
11466 .map_err(|error| invalid_feed(error.to_string()))?
11467 != changes_bytes
11468 || changes.get("v").and_then(Value::as_u64) != Some(2)
11469 || !changes.get("operations").is_some_and(Value::is_array)
11470 {
11471 return Err(invalid_feed("proposal changeset is not canonical v2"));
11472 }
11473 let blob_values = proposal
11474 .get("blobs")
11475 .and_then(Value::as_array)
11476 .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
11477 let mut blobs = Vec::with_capacity(blob_values.len());
11478 let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
11479 let mut prior_hash: Option<String> = None;
11480 for item in blob_values {
11481 let hash = item
11482 .get("sha256")
11483 .and_then(Value::as_str)
11484 .filter(|hash| is_sha256(hash))
11485 .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
11486 let bytes = item
11487 .get("bytes")
11488 .and_then(Value::as_u64)
11489 .filter(|bytes| *bytes <= MAX_STORE_BYTES)
11490 .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
11491 if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
11492 return Err(invalid_feed(
11493 "proposal blob declarations are not unique and sorted",
11494 ));
11495 }
11496 prior_hash = Some(hash.to_string());
11497 let endpoint = item
11498 .get("endpoint")
11499 .and_then(Value::as_str)
11500 .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
11501 let expected_endpoint = format!(
11502 "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
11503 head.brain_id
11504 );
11505 if endpoint != expected_endpoint {
11506 return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
11507 }
11508 descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
11509 blobs.push((hash.to_string(), bytes, endpoint.to_string()));
11510 }
11511 let descriptor = json!({
11512 "base": proposal.get("base").cloned().unwrap_or(Value::Null),
11513 "blobs": descriptor_blobs,
11514 "changes_base64": changes_b64,
11515 "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
11516 "v": 2,
11517 });
11518 let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
11519 .map_err(|error| invalid_feed(error.to_string()))?;
11520 if content_sha256(&descriptor_bytes) != clear_hash {
11521 return Err(invalid_feed(
11522 "proposal clear payload differs from its signed submission claim",
11523 ));
11524 }
11525 Ok(VerifiedV2Proposal {
11526 value,
11527 changes,
11528 blobs,
11529 })
11530}
11531
11532pub fn proposal_list(
11533 cfg: &HubConfig,
11534 brain: &str,
11535 state: &str,
11536 after: Option<&str>,
11537 limit: usize,
11538) -> LinkResult<Value> {
11539 require_safe_ref(brain)?;
11540 if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11541 return Err(invalid_feed("proposal state is invalid"));
11542 }
11543 if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11544 return Err(invalid_feed("proposal cursor is invalid"));
11545 }
11546 let head = v2_verified_head(cfg, brain)?
11547 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11548 let path = format!(
11549 "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11550 head.brain_id,
11551 limit.clamp(1, 100),
11552 after.map_or_else(String::new, |value| format!("&after={value}"))
11553 );
11554 ensure_ok(
11555 request_capped(
11556 cfg,
11557 "GET",
11558 &path,
11559 None,
11560 Auth::Required,
11561 MAX_FEED_RESPONSE_BYTES,
11562 )?,
11563 "v2 proposal list",
11564 )
11565}
11566
11567pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11568 require_safe_ref(brain)?;
11569 let head = v2_verified_head(cfg, brain)?
11570 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11571 Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11572}
11573
11574pub fn proposal_reject(
11575 cfg: &HubConfig,
11576 brain: &str,
11577 proposal_id: &str,
11578 mutation_id: &str,
11579 reason: &str,
11580) -> LinkResult<Value> {
11581 require_safe_ref(brain)?;
11582 require_proposal_id(proposal_id)?;
11583 let head = v2_verified_head(cfg, brain)?
11584 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11585 let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
11586 let body = json!({
11587 "mutation_id": mutation_id,
11588 "control_revision": head.control_revision,
11589 "reason": reason,
11590 });
11591 let path = format!(
11592 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11593 head.brain_id
11594 );
11595 ensure_ok(
11596 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11597 "v2 proposal rejection",
11598 )
11599}
11600
11601pub fn proposal_accept_exact(
11602 cfg: &HubConfig,
11603 brain: &str,
11604 proposal_id: &str,
11605 mutation_id: &str,
11606 reason: &str,
11607) -> LinkResult<Value> {
11608 require_safe_ref(brain)?;
11609 require_proposal_id(proposal_id)?;
11610 let head = v2_verified_head(cfg, brain)?
11611 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11612 let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
11613 let operations = proposal
11614 .changes
11615 .get("operations")
11616 .and_then(Value::as_array)
11617 .cloned()
11618 .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
11619 if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
11620 return Err(invalid_feed("proposal operation count is invalid"));
11621 }
11622 let mut downloaded = std::collections::BTreeMap::new();
11623 for (hash, bytes, endpoint) in &proposal.blobs {
11624 let body = ensure_raw_ok(
11625 request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
11626 "v2 proposal blob",
11627 )?;
11628 if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
11629 return Err(invalid_feed("proposal blob does not match its declaration"));
11630 }
11631 downloaded.insert(hash.clone(), body);
11632 }
11633 let remote = files_for_v2_view(
11634 &head,
11635 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
11636 );
11637 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
11638 let mut expected_candidate = remote.clone();
11639 let mut expected_candidate_assets = remote_assets;
11640 for operation in &operations {
11641 let op = operation
11642 .get("op")
11643 .and_then(Value::as_str)
11644 .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
11645 match op {
11646 "put" | "restore" => {
11647 let path = operation
11648 .get("path")
11649 .and_then(Value::as_str)
11650 .ok_or_else(|| invalid_feed("proposal write has no path"))?;
11651 crate::linkmd_v2::normalize_path(path)
11652 .map_err(|error| invalid_feed(error.to_string()))?;
11653 let hash = operation
11654 .get("blob")
11655 .and_then(Value::as_str)
11656 .filter(|hash| is_sha256(hash))
11657 .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
11658 let bytes = operation
11659 .get("bytes")
11660 .and_then(Value::as_u64)
11661 .ok_or_else(|| invalid_feed("proposal write has no size"))?;
11662 expected_candidate.insert(
11663 path.to_string(),
11664 V2BaselineFile {
11665 sha256: hash.to_string(),
11666 bytes,
11667 proof: None,
11668 },
11669 );
11670 }
11671 "delete" | "withdraw_from_hosting" => {
11672 let path = operation
11673 .get("path")
11674 .and_then(Value::as_str)
11675 .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
11676 crate::linkmd_v2::normalize_path(path)
11677 .map_err(|error| invalid_feed(error.to_string()))?;
11678 expected_candidate.remove(path);
11679 }
11680 "rename" => {
11681 let from = operation
11682 .get("from")
11683 .and_then(Value::as_str)
11684 .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
11685 let to = operation
11686 .get("to")
11687 .and_then(Value::as_str)
11688 .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
11689 crate::linkmd_v2::normalize_path(from)
11690 .and_then(|_| crate::linkmd_v2::normalize_path(to))
11691 .map_err(|error| invalid_feed(error.to_string()))?;
11692 let hash = operation
11693 .get("blob")
11694 .and_then(Value::as_str)
11695 .filter(|hash| is_sha256(hash))
11696 .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
11697 let bytes = operation
11698 .get("bytes")
11699 .and_then(Value::as_u64)
11700 .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
11701 expected_candidate.remove(from);
11702 expected_candidate.insert(
11703 to.to_string(),
11704 V2BaselineFile {
11705 sha256: hash.to_string(),
11706 bytes,
11707 proof: None,
11708 },
11709 );
11710 }
11711 "asset_delete" => {
11712 let path = operation
11713 .get("path")
11714 .and_then(Value::as_str)
11715 .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
11716 expected_candidate_assets.remove(path);
11717 }
11718 "asset_withdraw" => {
11719 let path = operation
11720 .get("path")
11721 .and_then(Value::as_str)
11722 .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
11723 let asset = expected_candidate_assets
11724 .get_mut(path)
11725 .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
11726 asset.disposition = "withheld".to_string();
11727 asset.leaf_hash.clear();
11728 }
11729 "asset_put" | "asset_resume" => {
11730 let path = operation
11731 .get("path")
11732 .and_then(Value::as_str)
11733 .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
11734 let asset = operation
11735 .get("asset")
11736 .and_then(Value::as_object)
11737 .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
11738 let blob_sha256 = asset
11739 .get("blob_sha256")
11740 .and_then(Value::as_str)
11741 .filter(|hash| is_sha256(hash))
11742 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
11743 let bytes = asset
11744 .get("bytes")
11745 .and_then(Value::as_u64)
11746 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
11747 let media_type = asset
11748 .get("media_type")
11749 .and_then(Value::as_str)
11750 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
11751 let wrappers = asset
11752 .get("wrappers")
11753 .and_then(Value::as_array)
11754 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
11755 .iter()
11756 .map(|wrapper| {
11757 wrapper
11758 .as_str()
11759 .map(str::to_string)
11760 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
11761 })
11762 .collect::<LinkResult<Vec<_>>>()?;
11763 let required = asset
11764 .get("required")
11765 .and_then(Value::as_bool)
11766 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
11767 let disposition = asset
11768 .get("disposition")
11769 .and_then(Value::as_str)
11770 .filter(|value| matches!(*value, "hosted" | "withheld"))
11771 .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
11772 expected_candidate_assets.insert(
11773 path.to_string(),
11774 V2BaselineAsset {
11775 blob_sha256: blob_sha256.to_string(),
11776 bytes,
11777 media_type: media_type.to_string(),
11778 wrappers,
11779 required,
11780 disposition: disposition.to_string(),
11781 leaf_hash: String::new(),
11782 },
11783 );
11784 }
11785 _ => return Err(invalid_feed("proposal operation kind is unsupported")),
11786 }
11787 }
11788 let base = head.pointer.as_ref().map(|pointer| {
11789 json!({
11790 "seq": pointer.seq,
11791 "commit_hash": pointer.commit_hash,
11792 "content_root": pointer.content_root,
11793 "asset_root": pointer.asset_root,
11794 })
11795 });
11796 let mut body = json!({
11797 "mutation_id": mutation_id,
11798 "base": base,
11799 "rebase": "strict",
11800 "reason": reason,
11801 "operations": operations,
11802 "blobs": downloaded
11803 .iter()
11804 .map(|(sha256, bytes)| json!({
11805 "sha256": sha256,
11806 "bytes": bytes.len(),
11807 "content_base64": STANDARD.encode(bytes),
11808 }))
11809 .collect::<Vec<_>>(),
11810 "proposal_id": proposal_id,
11811 "proposal_mode": "exact",
11812 });
11813 let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
11814 total
11815 .checked_add(bytes.len())
11816 .ok_or_else(|| LinkError::PushTooLarge {
11817 detail: "proposal changed-byte total overflow".to_string(),
11818 })
11819 })?;
11820 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
11821 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11822 for operation in &operations {
11823 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
11824 return Err(invalid_feed("proposal upload operation has no kind"));
11825 };
11826 let hash = match kind {
11827 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
11828 "asset_put" | "asset_resume" => operation
11829 .get("asset")
11830 .and_then(|asset| asset.get("blob_sha256"))
11831 .and_then(Value::as_str),
11832 _ => None,
11833 };
11834 let Some(hash) = hash else { continue };
11835 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
11836 if kind == "rename" {
11837 for field in ["from", "to"] {
11838 coordinates.insert(
11839 operation
11840 .get(field)
11841 .and_then(Value::as_str)
11842 .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
11843 .to_string(),
11844 );
11845 }
11846 } else {
11847 let path = operation
11848 .get("path")
11849 .and_then(Value::as_str)
11850 .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
11851 coordinates.insert(if kind.starts_with("asset_") {
11852 format!("assets/{path}")
11853 } else {
11854 path.to_string()
11855 });
11856 }
11857 }
11858 let declarations = downloaded
11859 .iter()
11860 .map(|(sha256, bytes)| {
11861 json!({
11862 "sha256": sha256,
11863 "bytes": bytes.len(),
11864 "coordinates": coordinates_by_hash
11865 .get(sha256)
11866 .into_iter()
11867 .flatten()
11868 .collect::<Vec<_>>(),
11869 })
11870 })
11871 .collect::<Vec<_>>();
11872 let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
11873 for batch in batch_upload_declarations(declarations) {
11874 let reserved = reserve_upload_window(
11875 cfg,
11876 &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
11877 &json!({ "blobs": batch }),
11878 "prepare proposal blob transport",
11879 )?;
11880 let reserved_items = reserved
11881 .get("uploads")
11882 .and_then(Value::as_array)
11883 .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
11884 items.extend(reserved_items.iter().cloned());
11885 }
11886 if items.len() != downloaded.len() {
11887 return Err(invalid_feed("proposal upload reservation changed the set"));
11888 }
11889 let mut references = Vec::with_capacity(items.len());
11890 for item in items {
11891 let hash = item
11892 .get("sha256")
11893 .and_then(Value::as_str)
11894 .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
11895 let bytes = downloaded
11896 .get(hash)
11897 .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
11898 let reservation_id = item
11899 .get("reservation_id")
11900 .and_then(Value::as_str)
11901 .filter(|id| crate::ulid::is_ulid(id))
11902 .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
11903 let expected_coordinates = coordinates_by_hash
11904 .get(hash)
11905 .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
11906 let returned_coordinates = item
11907 .get("coordinates")
11908 .and_then(Value::as_array)
11909 .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
11910 if returned_coordinates.len() != expected_coordinates.len()
11911 || returned_coordinates
11912 .iter()
11913 .zip(expected_coordinates)
11914 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
11915 {
11916 return Err(invalid_feed(
11917 "proposal upload reservation changed its coordinates",
11918 ));
11919 }
11920 match item.get("status").and_then(Value::as_str) {
11921 Some("upload") => put_presigned(
11922 cfg,
11923 item.get("url")
11924 .and_then(Value::as_str)
11925 .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
11926 item.get("headers").unwrap_or(&Value::Null),
11927 bytes,
11928 )?,
11929 Some("already_present") => {}
11930 _ => return Err(invalid_feed("proposal upload status is invalid")),
11931 }
11932 references.push(json!({
11933 "sha256": hash,
11934 "bytes": bytes.len(),
11935 "reservation_id": reservation_id,
11936 }));
11937 }
11938 body["blobs"] = Value::Array(references);
11939 }
11940 stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
11944 let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
11945 let mut result = ensure_ok(
11946 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
11947 "exact proposal acceptance",
11948 )?;
11949 let mut candidate_hub_signer = None;
11950 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
11951 let request_id = result
11952 .get("request_id")
11953 .and_then(Value::as_str)
11954 .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
11955 .to_string();
11956 let challenge = result
11957 .get("signing_challenge")
11958 .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
11959 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
11960 cfg,
11961 &head,
11962 &expected_candidate,
11963 &expected_candidate_assets,
11964 mutation_id,
11965 &v2_signed_request_view(&body, &operations),
11966 challenge,
11967 )?;
11968 body["signing_challenge_id"] = Value::String(challenge_id);
11969 body["signature_base64url"] = Value::String(signature);
11970 candidate_hub_signer = Some(actor_signer);
11971 result = ensure_ok(
11972 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
11973 "signed exact proposal acceptance",
11974 )?;
11975 }
11976 let refreshed = v2_verified_head(cfg, brain)?
11977 .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
11978 if candidate_hub_signer
11979 .as_ref()
11980 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
11981 || refreshed
11982 .pointer
11983 .as_ref()
11984 .map(|pointer| pointer.commit_hash.as_str())
11985 != result.get("commit_hash").and_then(Value::as_str)
11986 || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11987 || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
11988 {
11989 return Err(LinkError::RemoteAdvancedDuringSync);
11990 }
11991 accept_v2_head(cfg, &refreshed)?;
11992 Ok(result)
11993}
11994
11995pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
12006 require_valid_handle(handle)?;
12007 if body.len() as u64 > MAX_PROPOSE_BYTES {
12008 return Err(LinkError::ProposeTooLarge {
12009 bytes: body.len() as u64,
12010 });
12011 }
12012 let payload = json!({ "app": app, "body": body });
12013 let (path, auth) = if crate::ulid::is_ulid(handle) {
12018 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
12019 } else {
12020 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
12021 };
12022 ensure_ok(
12023 request(cfg, "POST", &path, Some(&payload), auth)?,
12024 "propose",
12025 )
12026}
12027
12028#[derive(Debug, serde::Serialize)]
12034pub struct Head {
12035 pub brain: String,
12037 pub seq: u64,
12039 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
12041 pub updated_at: Option<String>,
12042 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
12044 pub feed_hash: Option<String>,
12045 pub verified: bool,
12048}
12049
12050struct BoundedVecVisitor<T, const MAX: usize> {
12051 label: &'static str,
12052 marker: std::marker::PhantomData<T>,
12053}
12054
12055impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
12056where
12057 T: Deserialize<'de>,
12058{
12059 type Value = Vec<T>;
12060
12061 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12062 write!(formatter, "at most {MAX} {}", self.label)
12063 }
12064
12065 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
12066 where
12067 A: serde::de::SeqAccess<'de>,
12068 {
12069 if sequence.size_hint().is_some_and(|size| size > MAX) {
12070 return Err(serde::de::Error::custom(format!(
12071 "{} exceeds the {MAX}-item limit",
12072 self.label
12073 )));
12074 }
12075 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
12076 while let Some(value) = sequence.next_element()? {
12077 if values.len() == MAX {
12078 return Err(serde::de::Error::custom(format!(
12079 "{} exceeds the {MAX}-item limit",
12080 self.label
12081 )));
12082 }
12083 values.push(value);
12084 }
12085 Ok(values)
12086 }
12087}
12088
12089fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
12090 deserializer: D,
12091 label: &'static str,
12092) -> Result<Vec<T>, D::Error>
12093where
12094 D: serde::Deserializer<'de>,
12095 T: Deserialize<'de>,
12096{
12097 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
12098 label,
12099 marker: std::marker::PhantomData,
12100 })
12101}
12102
12103fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
12104where
12105 D: serde::Deserializer<'de>,
12106{
12107 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
12108}
12109
12110fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12111where
12112 D: serde::Deserializer<'de>,
12113{
12114 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
12115}
12116
12117fn deserialize_previous_identities<'de, D>(
12118 deserializer: D,
12119) -> Result<Vec<PreviousIdentity>, D::Error>
12120where
12121 D: serde::Deserializer<'de>,
12122{
12123 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
12124 deserializer,
12125 "previous identities",
12126 )
12127}
12128
12129fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12130where
12131 D: serde::Deserializer<'de>,
12132{
12133 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
12134 deserializer,
12135 "rotation statements",
12136 )
12137}
12138
12139fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
12140where
12141 D: serde::Deserializer<'de>,
12142{
12143 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
12144}
12145
12146#[derive(Debug, Clone, Deserialize, Serialize)]
12147struct FeedFile {
12148 path: String,
12149 sha256: String,
12150 bytes: u64,
12151}
12152
12153#[cfg(test)]
12154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12155enum V1DisclosureError {
12156 DuplicateFile,
12157 DuplicateRemoved,
12158 PushManifestMismatch,
12159 EditMissingChange,
12160 EditFalseFile,
12161 RemovedMismatch,
12162}
12163
12164#[cfg(test)]
12168fn verify_v1_manifest_disclosure(
12169 kind: &str,
12170 previous: &[FeedFile],
12171 resulting: &[FeedFile],
12172 files: &[FeedFile],
12173 removed: &[String],
12174) -> Result<(), V1DisclosureError> {
12175 fn as_map(
12176 files: &[FeedFile],
12177 ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
12178 let mut result = std::collections::BTreeMap::new();
12179 for file in files {
12180 if result
12181 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
12182 .is_some()
12183 {
12184 return Err(V1DisclosureError::DuplicateFile);
12185 }
12186 }
12187 Ok(result)
12188 }
12189 let previous = as_map(previous)?;
12190 let resulting = as_map(resulting)?;
12191 let disclosed = as_map(files)?;
12192 let removed_set: std::collections::BTreeSet<&str> =
12193 removed.iter().map(String::as_str).collect();
12194 if removed_set.len() != removed.len() {
12195 return Err(V1DisclosureError::DuplicateRemoved);
12196 }
12197 let expected_removed: std::collections::BTreeSet<&str> = previous
12198 .keys()
12199 .copied()
12200 .filter(|path| !resulting.contains_key(path))
12201 .collect();
12202 if removed_set != expected_removed {
12203 return Err(V1DisclosureError::RemovedMismatch);
12204 }
12205 if kind == "push" {
12206 return if disclosed == resulting {
12207 Ok(())
12208 } else {
12209 Err(V1DisclosureError::PushManifestMismatch)
12210 };
12211 }
12212 if kind != "edit" {
12213 return Err(V1DisclosureError::EditFalseFile);
12214 }
12215 if disclosed
12216 .iter()
12217 .any(|(path, value)| resulting.get(path) != Some(value))
12218 {
12219 return Err(V1DisclosureError::EditFalseFile);
12220 }
12221 for (path, value) in &resulting {
12222 if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
12223 return Err(V1DisclosureError::EditMissingChange);
12224 }
12225 }
12226 Ok(())
12227}
12228
12229#[derive(Debug, Clone, Deserialize, Serialize)]
12230struct FeedEntry {
12231 v: u8,
12232 seq: u64,
12233 ts: String,
12234 brain: String,
12235 public_key: String,
12236 kind: String,
12237 op: String,
12238 pack_sha256: String,
12239 #[serde(deserialize_with = "deserialize_feed_files")]
12240 files: Vec<FeedFile>,
12241 #[serde(deserialize_with = "deserialize_removed_paths")]
12242 removed: Vec<String>,
12243 prev_entry_hash: Option<String>,
12244 sig: String,
12245}
12246
12247#[derive(Serialize)]
12248struct UnsignedFeedEntry<'a> {
12249 v: u8,
12250 seq: u64,
12251 ts: &'a str,
12252 brain: &'a str,
12253 public_key: &'a str,
12254 kind: &'a str,
12255 op: &'a str,
12256 pack_sha256: &'a str,
12257 files: &'a [FeedFile],
12258 removed: &'a [String],
12259 prev_entry_hash: &'a Option<String>,
12260}
12261
12262#[derive(Debug, Clone, Deserialize, Serialize)]
12263struct FeedItem {
12264 hash: String,
12265 entry: FeedEntry,
12266}
12267
12268#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12269struct FeedIdentity {
12270 fingerprint: String,
12271 #[serde(rename = "publicKeySpki")]
12272 public_key_spki: String,
12273 #[serde(default, deserialize_with = "deserialize_previous_identities")]
12277 previous: Vec<PreviousIdentity>,
12278 #[serde(default, deserialize_with = "deserialize_rotations")]
12281 rotations: Vec<String>,
12282}
12283
12284#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12285struct PreviousIdentity {
12286 fingerprint: String,
12287 #[serde(rename = "publicKeySpki")]
12288 public_key_spki: String,
12289}
12290
12291#[derive(Debug, Deserialize)]
12292struct FeedResponse {
12293 #[serde(rename = "headSeq")]
12294 head_seq: u64,
12295 #[serde(rename = "feedHash")]
12296 feed_hash: Option<String>,
12297 identity: Option<FeedIdentity>,
12298 #[serde(deserialize_with = "deserialize_feed_items")]
12299 entries: Vec<FeedItem>,
12300 #[serde(rename = "scopeLimited")]
12301 scope_limited: bool,
12302}
12303
12304#[derive(Debug, Deserialize, Serialize)]
12305#[serde(deny_unknown_fields)]
12306struct RotationStatement {
12307 v: u8,
12308 op: String,
12309 brain: String,
12310 public_key: String,
12311 new_brain: String,
12312 new_public_key: String,
12313 prior_head_seq: u64,
12314 prior_feed_hash: Option<String>,
12315 ts: String,
12316 sig: String,
12317}
12318
12319#[derive(Debug, Clone, Deserialize, Serialize)]
12320struct TrustState {
12321 v: u8,
12322 origin: String,
12323 #[serde(default)]
12327 requested: String,
12328 brain: String,
12330 #[serde(default, skip_serializing_if = "Option::is_none")]
12333 home: Option<String>,
12334 anchor: String,
12335 current: String,
12336 #[serde(rename = "headSeq")]
12337 head_seq: u64,
12338 #[serde(rename = "feedHash")]
12339 feed_hash: Option<String>,
12340 #[serde(default)]
12344 rotations: Vec<String>,
12345 #[serde(default, skip_serializing_if = "Option::is_none")]
12348 hub_signer: Option<String>,
12349 #[serde(default, skip_serializing_if = "Option::is_none")]
12352 protocol_profile: Option<String>,
12353}
12354
12355fn accepted_as_v2(state: &TrustState) -> bool {
12356 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
12357}
12358
12359fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
12360 let directory = open_trust_dir(cfg)?;
12361 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
12362 return Ok(true);
12363 }
12364 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
12365 return Ok(false);
12366 };
12367 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
12368}
12369
12370#[derive(Debug, Clone, Deserialize, Serialize)]
12371struct AliasBinding {
12372 v: u8,
12373 origin: String,
12374 requested: String,
12375 brain: String,
12376 #[serde(default, skip_serializing_if = "Option::is_none")]
12377 home: Option<String>,
12378}
12379
12380struct VerifiedRemote {
12381 head: Head,
12382 identity: Option<FeedIdentity>,
12383 head_entry: Option<FeedItem>,
12384 entries: Vec<FeedItem>,
12386 anchor: Option<String>,
12387}
12388
12389fn invalid_feed(message: impl Into<String>) -> LinkError {
12390 LinkError::InvalidFeed {
12391 message: message.into(),
12392 }
12393}
12394
12395fn is_sha256(value: &str) -> bool {
12396 value.len() == 64
12397 && value
12398 .bytes()
12399 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
12400}
12401
12402fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
12403 let der = URL_SAFE_NO_PAD
12404 .decode(public_key_spki)
12405 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
12406 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
12407 return Err(invalid_feed(
12408 "identity public key is not a valid Ed25519 SPKI",
12409 ));
12410 }
12411 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
12412}
12413
12414fn verify_identity_chain(
12418 identity: &FeedIdentity,
12419 pinned: Option<&TrustState>,
12420) -> LinkResult<String> {
12421 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
12422 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
12423 {
12424 return Err(invalid_feed(
12425 "identity rotation history exceeds the client cap",
12426 ));
12427 }
12428 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
12429 return Err(invalid_feed(
12430 "current identity fingerprint does not match its public key",
12431 ));
12432 }
12433 for previous in &identity.previous {
12434 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
12435 return Err(invalid_feed(
12436 "previous identity fingerprint does not match its public key",
12437 ));
12438 }
12439 }
12440 if identity.rotations.len() != identity.previous.len() {
12441 return Err(invalid_feed(
12442 "identity history is missing an old-key-signed rotation statement",
12443 ));
12444 }
12445
12446 let mut chain: Vec<(&str, &str)> = identity
12450 .previous
12451 .iter()
12452 .rev()
12453 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
12454 .collect();
12455 chain.push((&identity.fingerprint, &identity.public_key_spki));
12456
12457 for (index, raw) in identity.rotations.iter().enumerate() {
12458 let statement: RotationStatement = serde_json::from_str(raw)
12459 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
12460 let (old_fingerprint, old_spki) = chain[index];
12461 let (new_fingerprint, new_spki) = chain[index + 1];
12462 if statement.v != 1
12463 || statement.op != "rotate"
12464 || statement.brain != format!("ed25519:{old_fingerprint}")
12465 || statement.public_key != old_spki
12466 || statement.new_brain != format!("ed25519:{new_fingerprint}")
12467 || statement.new_public_key != new_spki
12468 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
12469 || (statement.prior_head_seq > 0
12470 && statement
12471 .prior_feed_hash
12472 .as_deref()
12473 .is_none_or(|hash| !is_sha256(hash)))
12474 {
12475 return Err(invalid_feed(
12476 "rotation statement does not connect adjacent identities",
12477 ));
12478 }
12479 let unsigned = serde_json::to_string(&UnsignedRotation {
12480 v: statement.v,
12481 op: &statement.op,
12482 brain: &statement.brain,
12483 public_key: &statement.public_key,
12484 new_brain: &statement.new_brain,
12485 new_public_key: &statement.new_public_key,
12486 prior_head_seq: statement.prior_head_seq,
12487 prior_feed_hash: statement.prior_feed_hash.as_deref(),
12488 ts: statement.ts.clone(),
12489 })
12490 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
12491 let exact = format!(
12492 "{},\"sig\":\"{}\"}}",
12493 &unsigned[..unsigned.len() - 1],
12494 statement.sig
12495 );
12496 if exact != *raw {
12497 return Err(invalid_feed(
12498 "rotation statement is not in normative serialization",
12499 ));
12500 }
12501 let der = URL_SAFE_NO_PAD
12502 .decode(old_spki)
12503 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
12504 let signature = URL_SAFE_NO_PAD
12505 .decode(&statement.sig)
12506 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
12507 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
12508 .verify(unsigned.as_bytes(), &signature)
12509 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
12510 if index > 0 {
12511 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
12512 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
12513 if statement.prior_head_seq < prior.prior_head_seq {
12514 return Err(invalid_feed("rotation feed boundaries move backward"));
12515 }
12516 }
12517 }
12518
12519 let anchor = format!("ed25519:{}", chain[0].0);
12520 let current = format!("ed25519:{}", identity.fingerprint);
12521 if let Some(pin) = pinned {
12522 if pin.anchor != anchor {
12523 return Err(invalid_feed(
12524 "served identity chain does not descend from the pinned anchor",
12525 ));
12526 }
12527 if !chain
12528 .iter()
12529 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12530 {
12531 return Err(invalid_feed(
12532 "served identity chain forked away from the last pinned identity",
12533 ));
12534 }
12535 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
12536 return Err(invalid_feed("served identity discarded its rotation chain"));
12537 }
12538 if pin.v >= 2
12539 && (identity.rotations.len() < pin.rotations.len()
12540 || identity.rotations[..pin.rotations.len()] != pin.rotations)
12541 {
12542 return Err(invalid_feed(
12543 "served identity rewrote the locally accepted rotation history",
12544 ));
12545 }
12546 }
12547 Ok(anchor)
12548}
12549
12550fn verify_rotation_feed_boundaries(
12551 identity: &FeedIdentity,
12552 pinned: Option<&TrustState>,
12553 observed: &[FeedItem],
12554 advertised_seq: u64,
12555) -> LinkResult<()> {
12556 let mut chain: Vec<String> = identity
12557 .previous
12558 .iter()
12559 .rev()
12560 .map(|previous| format!("ed25519:{}", previous.fingerprint))
12561 .collect();
12562 chain.push(format!("ed25519:{}", identity.fingerprint));
12563 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
12564
12565 for (index, raw) in identity.rotations.iter().enumerate() {
12566 let rotation: RotationStatement = serde_json::from_str(raw)
12567 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12568 if rotation.prior_head_seq > advertised_seq {
12569 return Err(invalid_feed(
12570 "rotation claims a feed boundary beyond the advertised head",
12571 ));
12572 }
12573 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
12574 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
12575 return Err(invalid_feed(
12576 "newly disclosed rotation predates the local feed checkpoint",
12577 ));
12578 }
12579 }
12580 let actual = if rotation.prior_head_seq == 0 {
12581 None
12582 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
12583 pinned.and_then(|pin| pin.feed_hash.as_deref())
12584 } else {
12585 observed
12586 .iter()
12587 .find(|item| item.entry.seq == rotation.prior_head_seq)
12588 .map(|item| item.hash.as_str())
12589 };
12590 if let Some(actual) = actual {
12591 if rotation.prior_feed_hash.as_deref() != Some(actual) {
12592 return Err(invalid_feed(
12593 "rotation statement does not commit the verified feed boundary",
12594 ));
12595 }
12596 } else if rotation.prior_head_seq == 0 {
12597 } else if pinned.is_some_and(|pin| {
12600 pinned_index.is_some_and(|pin_index| index >= pin_index)
12601 || rotation.prior_head_seq >= pin.head_seq
12602 }) {
12603 return Err(invalid_feed(
12604 "rotation feed boundary was not present in the verified chain",
12605 ));
12606 }
12607 }
12608 Ok(())
12609}
12610
12611fn reject_retired_signer_after_checkpoint(
12616 identity: &FeedIdentity,
12617 pinned: Option<&TrustState>,
12618 item: &FeedItem,
12619) -> LinkResult<()> {
12620 let Some(pin) = pinned else {
12621 return Ok(());
12622 };
12623 if item.entry.seq <= pin.head_seq {
12624 return Ok(());
12625 }
12626 let mut chain: Vec<String> = identity
12627 .previous
12628 .iter()
12629 .rev()
12630 .map(|previous| format!("ed25519:{}", previous.fingerprint))
12631 .collect();
12632 chain.push(format!("ed25519:{}", identity.fingerprint));
12633 let pinned_index = chain
12634 .iter()
12635 .position(|key| key == &pin.current)
12636 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
12637 let signer_index = chain
12638 .iter()
12639 .position(|key| key == &item.entry.brain)
12640 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
12641 if signer_index < pinned_index {
12642 return Err(invalid_feed(
12643 "a retired identity attempted to sign after the local checkpoint",
12644 ));
12645 }
12646 Ok(())
12647}
12648
12649fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
12650 let origin = normalized_origin(&cfg.hub)?;
12651 let key = format!(
12652 "{:x}",
12653 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
12654 );
12655 Ok(format!("{key}.json"))
12656}
12657
12658fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
12659 let origin = normalized_origin(&cfg.hub)?;
12660 let key = format!(
12661 "{:x}",
12662 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
12663 );
12664 Ok(format!("alias-{key}.json"))
12665}
12666
12667#[cfg(any(unix, windows))]
12668struct TrustLock {
12669 _file: std::fs::File,
12670}
12671
12672#[cfg(unix)]
12673fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12674 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12675
12676 let lock_string = format!(".{state_name}.lock");
12677 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
12678 let fd = unsafe {
12679 libc::openat(
12680 directory.as_raw_fd(),
12681 lock_name.as_ptr(),
12682 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12683 0o600,
12684 )
12685 };
12686 if fd < 0 {
12687 return Err(std::io::Error::last_os_error().into());
12688 }
12689 let file = unsafe { std::fs::File::from_raw_fd(fd) };
12690 if !file.metadata()?.is_file() {
12691 return Err(LinkError::UnsafePath { path: lock_string });
12692 }
12693 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
12694 return Err(std::io::Error::last_os_error().into());
12695 }
12696 Ok(TrustLock { _file: file })
12697}
12698
12699#[cfg(windows)]
12700fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12701 let lock_name = format!(".{state_name}.lock");
12702 let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
12703 Ok(TrustLock { _file: file })
12704}
12705
12706#[cfg(any(unix, windows))]
12707fn lock_trust_many(
12708 cfg: &HubConfig,
12709 directory: &std::fs::File,
12710 refs: &[&str],
12711) -> LinkResult<Vec<TrustLock>> {
12712 let mut names = refs
12713 .iter()
12714 .map(|reference| trust_file_name(cfg, reference))
12715 .collect::<LinkResult<Vec<_>>>()?;
12716 names.sort();
12717 names.dedup();
12718 names
12719 .iter()
12720 .map(|name| lock_trust_name(directory, name))
12721 .collect()
12722}
12723
12724#[cfg(not(any(unix, windows)))]
12725fn lock_trust_many(
12726 _cfg: &HubConfig,
12727 _directory: &TrustDirectory,
12728 _refs: &[&str],
12729) -> LinkResult<Vec<()>> {
12730 Err(LinkError::UnsupportedPlatform {
12731 operation: "verified link.md state",
12732 })
12733}
12734
12735#[cfg(any(unix, windows))]
12736type TrustDirectory = std::fs::File;
12737
12738#[cfg(not(any(unix, windows)))]
12739struct TrustDirectory;
12740
12741#[cfg(unix)]
12742fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12743 use std::os::fd::AsRawFd as _;
12744
12745 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
12746 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
12747 return Err(std::io::Error::last_os_error().into());
12748 }
12749 directory.sync_all()?;
12750 Ok(directory)
12751}
12752
12753#[cfg(windows)]
12754fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12755 let marker = cfg.state_dir.join("trust").join(".directory");
12756 crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
12757 Ok(crate::fsx::open_directory_nofollow(
12758 marker.parent().expect("trust marker has a parent"),
12759 )?)
12760}
12761
12762#[cfg(not(any(unix, windows)))]
12763fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12764 Err(LinkError::UnsupportedPlatform {
12765 operation: "verified link.md state",
12766 })
12767}
12768
12769#[cfg(unix)]
12770fn load_trust_in(
12771 cfg: &HubConfig,
12772 directory: &TrustDirectory,
12773 requested: &str,
12774) -> LinkResult<Option<TrustState>> {
12775 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12776
12777 let name_string = trust_file_name(cfg, requested)?;
12778 let name = c_name(name_string.as_bytes(), &name_string)?;
12779 let fd = unsafe {
12780 libc::openat(
12781 directory.as_raw_fd(),
12782 name.as_ptr(),
12783 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12784 )
12785 };
12786 if fd < 0 {
12787 let error = std::io::Error::last_os_error();
12788 if error.kind() == std::io::ErrorKind::NotFound {
12789 return Ok(None);
12790 }
12791 return Err(LinkError::UnsafePath { path: name_string });
12792 }
12793 let file = unsafe { std::fs::File::from_raw_fd(fd) };
12794 if !file.metadata()?.is_file() {
12795 return Err(LinkError::UnsafePath { path: name_string });
12796 }
12797 let mut bytes = Vec::new();
12798 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
12799 if bytes.len() > 1024 * 1024 {
12800 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
12801 }
12802 let mut state: TrustState = serde_json::from_slice(&bytes)
12803 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
12804 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
12805 return Err(invalid_feed(
12806 "local identity/feed checkpoint does not match this hub and brain",
12807 ));
12808 }
12809 if state.v == 1 {
12810 if state.brain != requested {
12814 return Err(invalid_feed(
12815 "legacy checkpoint is not bound to the requested brain id",
12816 ));
12817 }
12818 state.requested = requested.to_string();
12819 } else if state.requested != requested {
12820 return Err(invalid_feed(
12821 "local identity/feed checkpoint is bound to a different requested ref",
12822 ));
12823 }
12824 Ok(Some(state))
12825}
12826
12827#[cfg(windows)]
12828fn load_trust_in(
12829 cfg: &HubConfig,
12830 directory: &TrustDirectory,
12831 requested: &str,
12832) -> LinkResult<Option<TrustState>> {
12833 let name = trust_file_name(cfg, requested)?;
12834 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
12835 let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
12836 Ok(bytes) => bytes,
12837 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
12838 Err(_) => return Err(LinkError::UnsafePath { path: name }),
12839 };
12840 let mut state: TrustState = serde_json::from_slice(&bytes)
12841 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
12842 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
12843 return Err(invalid_feed(
12844 "local identity/feed checkpoint does not match this hub and brain",
12845 ));
12846 }
12847 if state.v == 1 {
12848 if state.brain != requested {
12849 return Err(invalid_feed(
12850 "legacy checkpoint is not bound to the requested brain id",
12851 ));
12852 }
12853 state.requested = requested.to_string();
12854 } else if state.requested != requested {
12855 return Err(invalid_feed(
12856 "local identity/feed checkpoint is bound to a different requested ref",
12857 ));
12858 }
12859 Ok(Some(state))
12860}
12861
12862#[cfg(not(any(unix, windows)))]
12863fn load_trust_in(
12864 _cfg: &HubConfig,
12865 _directory: &TrustDirectory,
12866 _brain: &str,
12867) -> LinkResult<Option<TrustState>> {
12868 Err(LinkError::UnsupportedPlatform {
12869 operation: "verified link.md state",
12870 })
12871}
12872
12873#[cfg(all(test, any(unix, windows)))]
12874fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
12875 let directory = open_trust_dir(cfg)?;
12876 load_trust_in(cfg, &directory, requested)
12877}
12878
12879#[cfg(unix)]
12880fn save_trust_in(
12881 cfg: &HubConfig,
12882 directory: &TrustDirectory,
12883 state: &TrustState,
12884) -> LinkResult<()> {
12885 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12886
12887 let name_string = trust_file_name(cfg, &state.requested)?;
12888 let name = c_name(name_string.as_bytes(), &name_string)?;
12889 let mut bytes = serde_json::to_vec(state)
12890 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
12891 bytes.push(b'\n');
12892
12893 let nonce = std::time::SystemTime::now()
12894 .duration_since(std::time::UNIX_EPOCH)
12895 .unwrap_or_default()
12896 .as_nanos();
12897 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
12898 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
12899 let fd = unsafe {
12900 libc::openat(
12901 directory.as_raw_fd(),
12902 temp.as_ptr(),
12903 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12904 0o600,
12905 )
12906 };
12907 if fd < 0 {
12908 return Err(std::io::Error::last_os_error().into());
12909 }
12910 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
12911 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
12912 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12913 return Err(error.into());
12914 }
12915 drop(file);
12916 if unsafe {
12917 libc::renameat(
12918 directory.as_raw_fd(),
12919 temp.as_ptr(),
12920 directory.as_raw_fd(),
12921 name.as_ptr(),
12922 )
12923 } != 0
12924 {
12925 let error = std::io::Error::last_os_error();
12926 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12927 return Err(error.into());
12928 }
12929 directory.sync_all()?;
12930 Ok(())
12931}
12932
12933#[cfg(windows)]
12934fn save_trust_in(
12935 cfg: &HubConfig,
12936 directory: &TrustDirectory,
12937 state: &TrustState,
12938) -> LinkResult<()> {
12939 let name = trust_file_name(cfg, &state.requested)?;
12940 let mut bytes = serde_json::to_vec(state)
12941 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
12942 bytes.push(b'\n');
12943 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
12944 Ok(())
12945}
12946
12947#[cfg(not(any(unix, windows)))]
12948fn save_trust_in(
12949 _cfg: &HubConfig,
12950 _directory: &TrustDirectory,
12951 _state: &TrustState,
12952) -> LinkResult<()> {
12953 Err(LinkError::UnsupportedPlatform {
12954 operation: "verified link.md state",
12955 })
12956}
12957
12958#[cfg(unix)]
12959fn load_alias_in(
12960 cfg: &HubConfig,
12961 directory: &TrustDirectory,
12962 requested: &str,
12963) -> LinkResult<Option<AliasBinding>> {
12964 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12965
12966 let name_string = alias_file_name(cfg, requested)?;
12967 let name = c_name(name_string.as_bytes(), &name_string)?;
12968 let fd = unsafe {
12969 libc::openat(
12970 directory.as_raw_fd(),
12971 name.as_ptr(),
12972 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12973 )
12974 };
12975 if fd < 0 {
12976 let error = std::io::Error::last_os_error();
12977 if error.kind() == std::io::ErrorKind::NotFound {
12978 return Ok(None);
12979 }
12980 return Err(LinkError::UnsafePath { path: name_string });
12981 }
12982 let file = unsafe { std::fs::File::from_raw_fd(fd) };
12983 if !file.metadata()?.is_file() {
12984 return Err(LinkError::UnsafePath { path: name_string });
12985 }
12986 let mut bytes = Vec::new();
12987 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
12988 if bytes.len() > 64 * 1024 {
12989 return Err(invalid_feed("local alias binding is oversized"));
12990 }
12991 let alias: AliasBinding = serde_json::from_slice(&bytes)
12992 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
12993 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
12994 {
12995 return Err(invalid_feed(
12996 "local alias binding does not match this hub and requested ref",
12997 ));
12998 }
12999 Ok(Some(alias))
13000}
13001
13002#[cfg(windows)]
13003fn load_alias_in(
13004 cfg: &HubConfig,
13005 directory: &TrustDirectory,
13006 requested: &str,
13007) -> LinkResult<Option<AliasBinding>> {
13008 let name = alias_file_name(cfg, requested)?;
13009 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13010 let bytes = match reader.read(Path::new(&name), 64 * 1024) {
13011 Ok(bytes) => bytes,
13012 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13013 Err(_) => return Err(LinkError::UnsafePath { path: name }),
13014 };
13015 let alias: AliasBinding = serde_json::from_slice(&bytes)
13016 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13017 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13018 {
13019 return Err(invalid_feed(
13020 "local alias binding does not match this hub and requested ref",
13021 ));
13022 }
13023 Ok(Some(alias))
13024}
13025
13026#[cfg(not(any(unix, windows)))]
13027fn load_alias_in(
13028 _cfg: &HubConfig,
13029 _directory: &TrustDirectory,
13030 _requested: &str,
13031) -> LinkResult<Option<AliasBinding>> {
13032 Err(LinkError::UnsupportedPlatform {
13033 operation: "verified link.md state",
13034 })
13035}
13036
13037#[cfg(unix)]
13038fn save_alias_in(
13039 cfg: &HubConfig,
13040 directory: &TrustDirectory,
13041 alias: &AliasBinding,
13042) -> LinkResult<()> {
13043 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13044
13045 let name_string = alias_file_name(cfg, &alias.requested)?;
13046 let name = c_name(name_string.as_bytes(), &name_string)?;
13047 let mut bytes = serde_json::to_vec(alias)
13048 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13049 bytes.push(b'\n');
13050 let nonce = std::time::SystemTime::now()
13051 .duration_since(std::time::UNIX_EPOCH)
13052 .unwrap_or_default()
13053 .as_nanos();
13054 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13055 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13056 let fd = unsafe {
13057 libc::openat(
13058 directory.as_raw_fd(),
13059 temp.as_ptr(),
13060 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13061 0o600,
13062 )
13063 };
13064 if fd < 0 {
13065 return Err(std::io::Error::last_os_error().into());
13066 }
13067 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13068 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13069 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13070 return Err(error.into());
13071 }
13072 drop(file);
13073 if unsafe {
13074 libc::renameat(
13075 directory.as_raw_fd(),
13076 temp.as_ptr(),
13077 directory.as_raw_fd(),
13078 name.as_ptr(),
13079 )
13080 } != 0
13081 {
13082 let error = std::io::Error::last_os_error();
13083 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13084 return Err(error.into());
13085 }
13086 directory.sync_all()?;
13087 Ok(())
13088}
13089
13090#[cfg(windows)]
13091fn save_alias_in(
13092 cfg: &HubConfig,
13093 directory: &TrustDirectory,
13094 alias: &AliasBinding,
13095) -> LinkResult<()> {
13096 let name = alias_file_name(cfg, &alias.requested)?;
13097 let mut bytes = serde_json::to_vec(alias)
13098 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13099 bytes.push(b'\n');
13100 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13101 Ok(())
13102}
13103
13104#[cfg(not(any(unix, windows)))]
13105fn save_alias_in(
13106 _cfg: &HubConfig,
13107 _directory: &TrustDirectory,
13108 _alias: &AliasBinding,
13109) -> LinkResult<()> {
13110 Err(LinkError::UnsupportedPlatform {
13111 operation: "verified link.md state",
13112 })
13113}
13114
13115fn load_canonical_pin(
13120 cfg: &HubConfig,
13121 directory: &TrustDirectory,
13122 requested: &str,
13123 resolved_brain: &str,
13124) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
13125 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
13126 if requested == resolved_brain {
13127 return Ok((canonical, None));
13128 }
13129
13130 let mut alias = load_alias_in(cfg, directory, requested)?;
13131 if let Some(binding) = &alias {
13132 if binding.brain != resolved_brain {
13133 return Err(LinkError::AliasRebindRequired {
13134 alias: requested.to_string(),
13135 from: binding.brain.clone(),
13136 to: resolved_brain.to_string(),
13137 });
13138 }
13139 return Ok((canonical, alias));
13140 }
13141
13142 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
13146 if legacy.brain != resolved_brain {
13147 return Err(invalid_feed(
13148 "legacy alias checkpoint names a different canonical brain",
13149 ));
13150 }
13151 if let Some(existing) = &canonical {
13152 if existing.brain != legacy.brain
13153 || existing.anchor != legacy.anchor
13154 || existing.current != legacy.current
13155 || existing.head_seq != legacy.head_seq
13156 || existing.feed_hash != legacy.feed_hash
13157 || existing.rotations != legacy.rotations
13158 {
13159 return Err(invalid_feed(
13160 "legacy alias checkpoint conflicts with the canonical checkpoint",
13161 ));
13162 }
13163 } else {
13164 let mut promoted = legacy.clone();
13165 promoted.requested = resolved_brain.to_string();
13166 promoted.home = None;
13167 save_trust_in(cfg, directory, &promoted)?;
13168 canonical = Some(promoted);
13169 }
13170 alias = Some(AliasBinding {
13171 v: 1,
13172 origin: normalized_origin(&cfg.hub)?,
13173 requested: requested.to_string(),
13174 brain: resolved_brain.to_string(),
13175 home: legacy.home,
13176 });
13177 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
13178 }
13179 Ok((canonical, alias))
13180}
13181
13182pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
13187 require_hardened_filesystem("verified alias rebind")?;
13188 require_safe_ref(alias)?;
13189 require_safe_ref(from)?;
13190 require_safe_ref(to)?;
13191 if crate::ulid::is_ulid(alias)
13192 || !crate::ulid::is_ulid(from)
13193 || !crate::ulid::is_ulid(to)
13194 || from == to
13195 {
13196 return Err(LinkError::InvalidPack {
13197 message:
13198 "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
13199 .to_string(),
13200 });
13201 }
13202
13203 let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
13204 message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
13205 })?;
13206 accept_v2_head(cfg, &verified)?;
13207
13208 let alias_response = ensure_ok(
13209 request(
13210 cfg,
13211 "GET",
13212 &format!("/api/hub/brains/{alias}/v2/head"),
13213 None,
13214 Auth::Required,
13215 )?,
13216 "resolve alias for explicit rebind",
13217 )?;
13218 let resolved: V2HeadResponse = serde_json::from_value(alias_response)
13219 .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
13220 if resolved.v != 2 || resolved.brain_id != to {
13221 return Err(LinkError::RemoteAdvancedDuringSync);
13222 }
13223
13224 let directory = open_trust_dir(cfg)?;
13225 let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
13226 let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
13227 message: "the requested alias has no existing local binding to replace".to_string(),
13228 })?;
13229 if binding.brain != from {
13230 return Err(LinkError::AliasRebindRequired {
13231 alias: alias.to_string(),
13232 from: binding.brain,
13233 to: to.to_string(),
13234 });
13235 }
13236 save_alias_in(
13237 cfg,
13238 &directory,
13239 &AliasBinding {
13240 v: 1,
13241 origin: normalized_origin(&cfg.hub)?,
13242 requested: alias.to_string(),
13243 brain: to.to_string(),
13244 home: binding.home,
13245 },
13246 )?;
13247 Ok(json!({
13248 "v": 2,
13249 "alias": alias,
13250 "from": from,
13251 "to": to,
13252 "outcome": "alias_rebound",
13253 }))
13254}
13255
13256fn save_canonical_pin_and_alias(
13257 cfg: &HubConfig,
13258 directory: &TrustDirectory,
13259 requested: &str,
13260 resolved_brain: &str,
13261 mut state: TrustState,
13262 existing_alias: Option<&AliasBinding>,
13263) -> LinkResult<()> {
13264 state.requested = resolved_brain.to_string();
13265 state.brain = resolved_brain.to_string();
13266 state.home = None;
13267 save_trust_in(cfg, directory, &state)?;
13268 if requested != resolved_brain {
13269 save_alias_in(
13270 cfg,
13271 directory,
13272 &AliasBinding {
13273 v: 1,
13274 origin: normalized_origin(&cfg.hub)?,
13275 requested: requested.to_string(),
13276 brain: resolved_brain.to_string(),
13277 home: existing_alias.and_then(|alias| alias.home.clone()),
13278 },
13279 )?;
13280 }
13281 Ok(())
13282}
13283
13284fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
13285 const ED25519_SPKI_PREFIX: &[u8] = &[
13286 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13287 ];
13288 let entry = &item.entry;
13289 let public_der = URL_SAFE_NO_PAD
13290 .decode(&entry.public_key)
13291 .map_err(|_| invalid_feed("public key is not base64url"))?;
13292 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
13293 || !public_der.starts_with(ED25519_SPKI_PREFIX)
13294 {
13295 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
13296 }
13297 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
13298 if entry.brain != format!("ed25519:{fingerprint}") {
13299 return Err(invalid_feed(
13300 "brain fingerprint does not match its public key",
13301 ));
13302 }
13303 let _ = verify_identity_chain(identity, None)?;
13305 let mut chain: Vec<(&str, &str)> = identity
13306 .previous
13307 .iter()
13308 .rev()
13309 .map(|previous| {
13310 (
13311 previous.fingerprint.as_str(),
13312 previous.public_key_spki.as_str(),
13313 )
13314 })
13315 .collect();
13316 chain.push((&identity.fingerprint, &identity.public_key_spki));
13317 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
13318 *known_fingerprint == fingerprint && *spki == entry.public_key
13319 });
13320 let Some(signer_index) = signer_index else {
13321 return Err(invalid_feed(
13322 "entry signer is not this brain's identity (current or rotated-from)",
13323 ));
13324 };
13325 let lower_boundary = if signer_index == 0 {
13326 None
13327 } else {
13328 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
13329 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13330 Some(prior.prior_head_seq)
13331 };
13332 let upper_boundary = if signer_index == identity.rotations.len() {
13333 None
13334 } else {
13335 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
13336 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13337 Some(next.prior_head_seq)
13338 };
13339 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
13340 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
13341 {
13342 return Err(invalid_feed(
13343 "entry signer is outside its authenticated rotation epoch",
13344 ));
13345 }
13346 let unsigned = UnsignedFeedEntry {
13347 v: entry.v,
13348 seq: entry.seq,
13349 ts: &entry.ts,
13350 brain: &entry.brain,
13351 public_key: &entry.public_key,
13352 kind: &entry.kind,
13353 op: &entry.op,
13354 pack_sha256: &entry.pack_sha256,
13355 files: &entry.files,
13356 removed: &entry.removed,
13357 prev_entry_hash: &entry.prev_entry_hash,
13358 };
13359 let message =
13360 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
13361 let signature = URL_SAFE_NO_PAD
13362 .decode(&entry.sig)
13363 .map_err(|_| invalid_feed("signature is not base64url"))?;
13364 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
13365 .verify(&message, &signature)
13366 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
13367
13368 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
13369 exact.push(b'\n');
13370 let actual_hash = format!("{:x}", Sha256::digest(&exact));
13371 if actual_hash != item.hash {
13372 return Err(invalid_feed("entry SHA-256 does not match"));
13373 }
13374 Ok(())
13375}
13376
13377#[derive(Serialize)]
13383struct UnsignedRotation<'a> {
13384 v: u8,
13385 op: &'a str,
13386 brain: &'a str,
13387 public_key: &'a str,
13388 new_brain: &'a str,
13389 new_public_key: &'a str,
13390 prior_head_seq: u64,
13391 prior_feed_hash: Option<&'a str>,
13392 ts: String,
13393}
13394
13395#[derive(Debug, Deserialize, Serialize)]
13400#[serde(deny_unknown_fields)]
13401struct RotationJournal {
13402 v: u8,
13403 origin: String,
13404 brain: String,
13405 old_brain: String,
13406 new_brain: String,
13407 prior_head_seq: u64,
13408 prior_feed_hash: Option<String>,
13409 statement: String,
13410}
13411
13412fn rotation_journal_path(key_path: &Path) -> PathBuf {
13413 let mut path = key_path.as_os_str().to_os_string();
13414 path.push(".rotation.json");
13415 PathBuf::from(path)
13416}
13417
13418fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
13419 #[cfg(unix)]
13420 let file = {
13421 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13422 use std::os::unix::ffi::OsStrExt as _;
13423 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13424 .map_err(|error| {
13425 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
13426 })?;
13427 let leaf_name = path
13428 .file_name()
13429 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
13430 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
13431 let fd = unsafe {
13432 libc::openat(
13433 parent.as_raw_fd(),
13434 leaf.as_ptr(),
13435 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13436 )
13437 };
13438 if fd < 0 {
13439 return Err(bad_agent_key(
13440 "the rotation journal must be an existing regular file without symlink ancestors",
13441 ));
13442 }
13443 unsafe { std::fs::File::from_raw_fd(fd) }
13444 };
13445 #[cfg(not(unix))]
13446 let file = std::fs::File::open(path)
13447 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
13448 let metadata = file
13449 .metadata()
13450 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
13451 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
13452 return Err(bad_agent_key(
13453 "the rotation journal must be a bounded regular file",
13454 ));
13455 }
13456 #[cfg(unix)]
13457 {
13458 use std::os::unix::fs::PermissionsExt as _;
13459 if metadata.permissions().mode() & 0o077 != 0 {
13460 return Err(bad_agent_key(
13461 "the rotation journal is accessible to group/other; set mode 0600",
13462 ));
13463 }
13464 }
13465 serde_json::from_reader(file)
13466 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
13467}
13468
13469fn remove_rotation_journal(path: &Path) {
13470 #[cfg(unix)]
13471 {
13472 use std::os::fd::AsRawFd as _;
13473 use std::os::unix::ffi::OsStrExt as _;
13474 let Ok(parent) =
13475 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13476 else {
13477 return;
13478 };
13479 let Some(leaf_name) = path.file_name() else {
13480 return;
13481 };
13482 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
13483 return;
13484 };
13485 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
13486 let _ = parent.sync_all();
13487 }
13488 }
13489 #[cfg(not(unix))]
13490 {
13491 let _ = std::fs::remove_file(path);
13492 }
13493}
13494
13495fn validate_rotation_journal(
13496 journal: &RotationJournal,
13497 cfg: &HubConfig,
13498 canonical_brain: &str,
13499 old_key: &AgentSigningKey,
13500 new_key: &AgentSigningKey,
13501 head: &Head,
13502) -> LinkResult<()> {
13503 if journal.v != 1
13504 || journal.origin != normalized_origin(&cfg.hub)?
13505 || journal.brain != canonical_brain
13506 || journal.old_brain != old_key.multikey
13507 || journal.new_brain != new_key.multikey
13508 || journal.prior_head_seq != head.seq
13509 || journal.prior_feed_hash != head.feed_hash
13510 {
13511 return Err(invalid_feed(
13512 "rotation journal does not match the verified key and feed boundary",
13513 ));
13514 }
13515 let statement: RotationStatement = serde_json::from_str(&journal.statement)
13516 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
13517 if statement.prior_head_seq != journal.prior_head_seq
13518 || statement.prior_feed_hash != journal.prior_feed_hash
13519 || statement.brain != old_key.multikey
13520 || statement.public_key != old_key.public_key_spki
13521 || statement.new_brain != new_key.multikey
13522 || statement.new_public_key != new_key.public_key_spki
13523 {
13524 return Err(invalid_feed(
13525 "rotation journal statement does not match its durable intent",
13526 ));
13527 }
13528 let identity = FeedIdentity {
13529 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13530 public_key_spki: new_key.public_key_spki.clone(),
13531 previous: vec![PreviousIdentity {
13532 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
13533 public_key_spki: old_key.public_key_spki.clone(),
13534 }],
13535 rotations: vec![journal.statement.clone()],
13536 };
13537 verify_identity_chain(&identity, None)?;
13538 Ok(())
13539}
13540
13541#[derive(Debug, Serialize)]
13543pub struct RotationReport {
13544 pub brain: String,
13546 pub multikey: String,
13548 #[serde(rename = "keyFile")]
13550 pub key_file: String,
13551 pub previous: Vec<String>,
13553}
13554
13555pub fn rotate_brain_key(
13561 cfg: &HubConfig,
13562 brain: &str,
13563 old_key: &AgentSigningKey,
13564 out: &Path,
13565) -> LinkResult<RotationReport> {
13566 require_hardened_filesystem("key rotation")?;
13567 require_safe_ref(brain)?;
13568 let new_key = if out.exists() {
13572 load_signing_key(out)?
13573 } else {
13574 let rng = ring::rand::SystemRandom::new();
13575 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
13576 .map_err(|_| bad_agent_key("key generation failed"))?;
13577 let pair = agent_keypair(pkcs8.as_ref())?;
13578 let (public_key_spki, multikey) = public_identity_for(&pair);
13579 write_secret_new(
13580 out,
13581 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
13582 )?;
13583 AgentSigningKey {
13584 pkcs8: pkcs8.as_ref().to_vec(),
13585 multikey,
13586 public_key_spki,
13587 }
13588 };
13589 let new_spki = new_key.public_key_spki.clone();
13590 let new_multikey = new_key.multikey.clone();
13591 let journal_path = rotation_journal_path(out);
13592 let before_v2 = v2_verified_head(cfg, brain)?;
13593 let (canonical_brain, served_identity, observed_head, v2_profile) =
13594 if let Some(head) = before_v2 {
13595 let observed = Head {
13596 brain: head.brain_id.clone(),
13597 seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13598 updated_at: head
13599 .pointer
13600 .as_ref()
13601 .map(|pointer| pointer.signed_at.clone()),
13602 feed_hash: head
13603 .pointer
13604 .as_ref()
13605 .map(|pointer| pointer.feed_hash.clone()),
13606 verified: true,
13607 };
13608 let identity = v2_identity(&head.identity);
13609 let canonical = head.brain_id.clone();
13610 accept_v2_head(cfg, &head)?;
13611 (canonical, identity, observed, true)
13612 } else {
13613 let remote = verified_remote_head(cfg, brain, false)?;
13614 let identity = remote
13615 .identity
13616 .clone()
13617 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
13618 (remote.head.brain.clone(), identity, remote.head, false)
13619 };
13620 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
13621 let already_rotated = served_multikey == new_multikey;
13622 if already_rotated && !journal_path.exists() {
13627 remove_rotation_journal(&journal_path);
13628 return Ok(RotationReport {
13629 brain: brain.to_string(),
13630 multikey: new_multikey,
13631 key_file: out.display().to_string(),
13632 previous: served_identity
13633 .previous
13634 .iter()
13635 .map(|identity| format!("ed25519:{}", identity.fingerprint))
13636 .collect(),
13637 });
13638 }
13639 if !already_rotated && served_multikey != old_key.multikey {
13640 return Err(invalid_feed(
13641 "the supplied old key is not the brain's verified current identity",
13642 ));
13643 }
13644
13645 let journal = if journal_path.exists() {
13646 read_rotation_journal(&journal_path)?
13647 } else {
13648 let ts = crate::now()
13649 .with_timezone(&chrono::Utc)
13650 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
13651 .to_string();
13652 let unsigned = serde_json::to_string(&UnsignedRotation {
13653 v: 1,
13654 op: "rotate",
13655 brain: &old_key.multikey,
13656 public_key: &old_key.public_key_spki,
13657 new_brain: &new_multikey,
13658 new_public_key: &new_spki,
13659 prior_head_seq: observed_head.seq,
13660 prior_feed_hash: observed_head.feed_hash.as_deref(),
13661 ts,
13662 })
13663 .expect("serialize rotation");
13664 let old_pair = agent_keypair(&old_key.pkcs8)?;
13665 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
13666 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
13667 let journal = RotationJournal {
13668 v: 1,
13669 origin: normalized_origin(&cfg.hub)?,
13670 brain: canonical_brain.clone(),
13671 old_brain: old_key.multikey.clone(),
13672 new_brain: new_multikey.clone(),
13673 prior_head_seq: observed_head.seq,
13674 prior_feed_hash: observed_head.feed_hash.clone(),
13675 statement,
13676 };
13677 let mut exact = serde_json::to_vec(&journal)
13678 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
13679 exact.push(b'\n');
13680 if write_secret_new(&journal_path, &exact).is_err() {
13681 read_rotation_journal(&journal_path)?
13684 } else {
13685 journal
13686 }
13687 };
13688 validate_rotation_journal(
13689 &journal,
13690 cfg,
13691 &canonical_brain,
13692 old_key,
13693 &new_key,
13694 &observed_head,
13695 )?;
13696
13697 let body = json!({ "statement": journal.statement });
13698 let path = format!("/api/hub/brains/{brain}/rotate");
13699 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
13700 let attempted_failure = match attempted {
13701 Ok(response) if (200..300).contains(&response.status) => None,
13702 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
13703 Err(error) => Some(error),
13704 };
13705
13706 let identity = if v2_profile {
13710 match v2_verified_head(cfg, brain) {
13711 Ok(Some(after)) => {
13712 let identity = v2_identity(&after.identity);
13713 accept_v2_head(cfg, &after)?;
13714 identity
13715 }
13716 Ok(None) => {
13717 return Err(attempted_failure.unwrap_or_else(|| {
13718 invalid_feed("rotated v2 brain no longer serves a v2 head")
13719 }));
13720 }
13721 Err(error) => return Err(attempted_failure.unwrap_or(error)),
13722 }
13723 } else {
13724 match verified_remote_head(cfg, brain, false) {
13725 Ok(after) => after
13726 .identity
13727 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
13728 Err(error) => return Err(attempted_failure.unwrap_or(error)),
13729 }
13730 };
13731 if format!("ed25519:{}", identity.fingerprint) != new_multikey
13732 || identity.public_key_spki != new_spki
13733 {
13734 return Err(attempted_failure.unwrap_or_else(|| {
13735 invalid_feed("hub acknowledged rotation without committing the verified new identity")
13736 }));
13737 }
13738 if v2_profile {
13739 if let Some(error) = attempted_failure {
13740 return Err(error);
13745 }
13746 }
13747 let previous = identity
13748 .previous
13749 .iter()
13750 .map(|prior| format!("ed25519:{}", prior.fingerprint))
13751 .collect();
13752 remove_rotation_journal(&journal_path);
13753
13754 Ok(RotationReport {
13755 brain: brain.to_string(),
13756 multikey: new_multikey,
13757 key_file: out.display().to_string(),
13758 previous,
13759 })
13760}
13761
13762#[derive(Debug, Serialize)]
13768pub struct MirrorReport {
13769 pub brain: String,
13771 #[serde(rename = "headSeq")]
13773 pub head_seq: u64,
13774 #[serde(rename = "feedHash")]
13776 pub feed_hash: Option<String>,
13777 pub entries: u64,
13779 pub pinned: String,
13781 pub files: usize,
13783}
13784
13785pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
13787
13788#[derive(Debug)]
13790pub struct VerifiedMirrorMaterial {
13791 pub brain: String,
13792 pub head_seq: u64,
13793 pub feed_hash: Option<String>,
13794 pub identity: serde_json::Value,
13795 pub entries: Vec<(u64, String, String)>,
13797 pub pack_sha256: Option<String>,
13798}
13799
13800#[derive(Deserialize)]
13801#[serde(deny_unknown_fields)]
13802struct StoredMirrorHead {
13803 brain: String,
13804 #[serde(rename = "headSeq")]
13805 head_seq: u64,
13806 #[serde(rename = "feedHash")]
13807 feed_hash: Option<String>,
13808}
13809
13810pub fn verify_mirror_material(
13813 head_bytes: &[u8],
13814 identity_bytes: &[u8],
13815 feed_bytes: &[Vec<u8>],
13816 snapshot_pack: Option<&[u8]>,
13817 expected_anchor: &str,
13818) -> LinkResult<VerifiedMirrorMaterial> {
13819 let snapshot_hash = snapshot_pack
13820 .filter(|pack| !pack.is_empty())
13821 .map(content_sha256);
13822 verify_mirror_material_with_pack_hash(
13823 head_bytes,
13824 identity_bytes,
13825 feed_bytes,
13826 snapshot_hash.as_deref(),
13827 expected_anchor,
13828 )
13829}
13830
13831pub fn verify_mirror_material_with_pack_hash(
13835 head_bytes: &[u8],
13836 identity_bytes: &[u8],
13837 feed_bytes: &[Vec<u8>],
13838 snapshot_pack_sha256: Option<&str>,
13839 expected_anchor: &str,
13840) -> LinkResult<VerifiedMirrorMaterial> {
13841 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
13842 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
13843 require_safe_ref(&head.brain)?;
13844 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
13845 return Err(invalid_feed(
13846 "stored mirror feed count does not match its bounded head sequence",
13847 ));
13848 }
13849 let aggregate = feed_bytes
13850 .iter()
13851 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
13852 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
13853 if aggregate > MAX_FEED_REPLAY_BYTES {
13854 return Err(invalid_feed(
13855 "stored mirror feed metadata exceeds the aggregate limit",
13856 ));
13857 }
13858 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
13859 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
13860 let anchor = verify_identity_chain(&identity, None)?;
13861 if anchor != expected_anchor {
13862 return Err(invalid_feed(
13863 "stored mirror identity does not descend from the explicitly trusted anchor",
13864 ));
13865 }
13866
13867 let mut entries = Vec::with_capacity(feed_bytes.len());
13868 let mut items = Vec::with_capacity(feed_bytes.len());
13869 let mut previous_hash = None;
13870 let mut pack_sha256 = None;
13871 for (index, bytes) in feed_bytes.iter().enumerate() {
13872 let exact = bytes
13873 .strip_suffix(b"\n")
13874 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
13875 if exact.ends_with(b"\n") {
13876 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
13877 }
13878 let entry: FeedEntry = serde_json::from_slice(exact)
13879 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
13880 let expected_seq = index as u64 + 1;
13881 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
13882 return Err(invalid_feed(
13883 "stored mirror feed is not contiguous and hash-chained",
13884 ));
13885 }
13886 let canonical = serde_json::to_vec(&entry)
13887 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
13888 if canonical != exact {
13889 return Err(invalid_feed(
13890 "stored feed entry is not in normative serialization",
13891 ));
13892 }
13893 let hash = content_sha256(bytes);
13894 let item = FeedItem {
13895 hash: hash.clone(),
13896 entry,
13897 };
13898 verify_feed_item(&item, &identity)?;
13899 previous_hash = Some(hash.clone());
13900 if expected_seq == head.head_seq {
13901 pack_sha256 = Some(item.entry.pack_sha256.clone());
13902 }
13903 entries.push((
13904 expected_seq,
13905 std::str::from_utf8(exact)
13906 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
13907 .to_string(),
13908 hash,
13909 ));
13910 items.push(item);
13911 }
13912 if previous_hash != head.feed_hash {
13913 return Err(invalid_feed(
13914 "stored mirror feed does not converge on its advertised head",
13915 ));
13916 }
13917 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
13918 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
13919 (0, None, None) => {}
13920 (_, Some(actual), Some(expected)) if actual == expected => {}
13921 _ => {
13922 return Err(LinkError::InvalidPack {
13923 message: "stored snapshot pack does not match the signed head digest".to_string(),
13924 });
13925 }
13926 }
13927 let identity_value = serde_json::to_value(&identity)
13928 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
13929 Ok(VerifiedMirrorMaterial {
13930 brain: head.brain,
13931 head_seq: head.head_seq,
13932 feed_hash: head.feed_hash,
13933 identity: identity_value,
13934 entries,
13935 pack_sha256,
13936 })
13937}
13938
13939pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
13942 format!(
13943 "{:x}",
13944 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
13945 )
13946}
13947
13948pub fn content_sha256(bytes: &[u8]) -> String {
13951 format!("{:x}", Sha256::digest(bytes))
13952}
13953
13954pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
13956 let mut digest = Sha256::new();
13957 let mut buffer = [0u8; 64 * 1024];
13958 loop {
13959 let read = reader.read(&mut buffer)?;
13960 if read == 0 {
13961 break;
13962 }
13963 digest.update(&buffer[..read]);
13964 }
13965 Ok(format!("{:x}", digest.finalize()))
13966}
13967
13968#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
13976pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
13977 require_hardened_filesystem("mirror")?;
13978 require_safe_ref(brain)?;
13979 #[cfg(windows)]
13980 {
13981 let _ = (cfg, dest);
13982 return Err(LinkError::UnsupportedPlatform {
13983 operation: "atomic whole-mirror replacement on Windows",
13984 });
13985 }
13986 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
13987 let name = dest
13988 .file_name()
13989 .and_then(|name| name.to_str())
13990 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
13991 .ok_or_else(|| LinkError::UnsafePath {
13992 path: dest.display().to_string(),
13993 })?;
13994 #[cfg(unix)]
13995 let parent_dir = open_or_create_dir_nofollow(parent)?;
13996 #[cfg(unix)]
13997 use std::os::fd::AsRawFd as _;
13998 #[cfg(unix)]
13999 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
14000 #[cfg(unix)]
14001 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
14002 None => false,
14003 Some(true) => true,
14004 Some(false) => {
14005 return Err(LinkError::UnsafePath {
14006 path: dest.display().to_string(),
14007 });
14008 }
14009 };
14010
14011 #[cfg(unix)]
14014 let legacy_backup_name = c_name(
14015 format!(".{name}.dbmd-backup").as_bytes(),
14016 &dest.display().to_string(),
14017 )?;
14018 #[cfg(unix)]
14019 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
14020 return Err(LinkError::UnsafePath {
14021 path: parent
14022 .join(format!(".{name}.dbmd-backup"))
14023 .display()
14024 .to_string(),
14025 });
14026 }
14027
14028 let nonce = std::time::SystemTime::now()
14029 .duration_since(std::time::UNIX_EPOCH)
14030 .unwrap_or_default()
14031 .as_nanos();
14032 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
14033 #[cfg(unix)]
14034 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
14035 #[cfg(unix)]
14036 let stage_dir = create_dir_exclusive_at(
14037 parent_dir.as_raw_fd(),
14038 &stage_name,
14039 &dest.display().to_string(),
14040 )?;
14041
14042 let assembled = (|| -> LinkResult<MirrorReport> {
14043 let remote = verified_remote_head(cfg, brain, true)?;
14044 let brain_id = remote.head.brain.clone();
14045 let identity = remote
14046 .identity
14047 .as_ref()
14048 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
14049 let anchor = remote
14050 .anchor
14051 .clone()
14052 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
14053 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
14054 let snapshot_entries = parse_store_pack(pack.clone())?;
14055 let snapshot_count = snapshot_entries.len();
14056 let mut staged_entries = snapshot_entries;
14057 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
14058 for item in &remote.entries {
14059 let mut exact = serde_json::to_vec(&item.entry)
14060 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
14061 exact.push(b'\n');
14062 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
14063 return Err(invalid_feed(
14064 "serialized mirror entry differs from its verified hash",
14065 ));
14066 }
14067 staged_entries.push((
14068 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
14069 exact,
14070 ));
14071 }
14072 let mut identity_bytes = serde_json::to_vec(identity)
14073 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
14074 identity_bytes.push(b'\n');
14075 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
14076 let mut head_bytes = serde_json::to_vec(&json!({
14077 "brain": brain_id,
14078 "headSeq": remote.head.seq,
14079 "feedHash": remote.head.feed_hash,
14080 }))
14081 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
14082 head_bytes.push(b'\n');
14083 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
14084 staged_entries.push((
14085 CONFIG_REL_PATH.to_string(),
14086 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
14087 ));
14088 #[cfg(unix)]
14089 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
14090
14091 Ok(MirrorReport {
14092 brain: brain_id,
14093 head_seq: remote.head.seq,
14094 feed_hash: remote.head.feed_hash,
14095 entries: remote.entries.len() as u64,
14096 pinned: anchor,
14097 files: snapshot_count,
14098 })
14099 })();
14100
14101 let report = match assembled {
14102 Ok(report) => report,
14103 Err(error) => {
14104 #[cfg(unix)]
14105 let _ = remove_tree_at(
14106 parent_dir.as_raw_fd(),
14107 &stage_name,
14108 &dest.display().to_string(),
14109 );
14110 return Err(error);
14111 }
14112 };
14113
14114 #[cfg(unix)]
14115 if let Err(error) =
14116 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
14117 {
14118 let _ = remove_tree_at(
14119 parent_dir.as_raw_fd(),
14120 &stage_name,
14121 &dest.display().to_string(),
14122 );
14123 return Err(error);
14124 }
14125 #[cfg(unix)]
14128 if dest_exists {
14129 remove_tree_at(
14130 parent_dir.as_raw_fd(),
14131 &stage_name,
14132 &dest.display().to_string(),
14133 )?;
14134 }
14135 #[cfg(unix)]
14136 parent_dir.sync_all()?;
14137 Ok(report)
14138}
14139
14140fn verified_remote_head(
14141 cfg: &HubConfig,
14142 brain: &str,
14143 require_full_chain: bool,
14144) -> LinkResult<VerifiedRemote> {
14145 require_hardened_filesystem("verified link.md state")?;
14146 require_safe_ref(brain)?;
14147 let trust_directory = open_trust_dir(cfg)?;
14151 let path = format!("/api/hub/brains/{brain}");
14152 let body = ensure_ok(
14153 request(cfg, "GET", &path, None, Auth::Required)?,
14154 "subscribe",
14155 )?;
14156 let resolved_brain = body
14157 .get("id")
14158 .and_then(Value::as_str)
14159 .filter(|id| crate::ulid::is_ulid(id))
14160 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
14161 .to_string();
14162 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
14163 return Err(invalid_feed(
14164 "brain card id differs from the explicitly requested brain id",
14165 ));
14166 }
14167 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
14172 let (pinned, alias_binding) =
14173 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
14174 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
14175 let advertised_hash = body
14176 .get("feedHash")
14177 .and_then(Value::as_str)
14178 .map(str::to_string);
14179 let updated_at = body
14180 .get("updatedAt")
14181 .and_then(Value::as_str)
14182 .map(str::to_string);
14183 if let Some(pin) = &pinned {
14184 if seq < pin.head_seq {
14185 return Err(invalid_feed(format!(
14186 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
14187 pin.head_seq
14188 )));
14189 }
14190 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
14191 return Err(invalid_feed(
14192 "feed equivocation: the checkpoint sequence now has a different hash",
14193 ));
14194 }
14195 }
14196 if seq == 0 {
14197 if advertised_hash.is_some() {
14198 return Err(invalid_feed("an empty feed advertised a head hash"));
14199 }
14200 let identity: FeedIdentity = serde_json::from_value(
14201 body.get("identity")
14202 .cloned()
14203 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
14204 )
14205 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
14206 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
14207 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
14212 save_canonical_pin_and_alias(
14213 cfg,
14214 &trust_directory,
14215 brain,
14216 &resolved_brain,
14217 TrustState {
14218 v: 2,
14219 origin: normalized_origin(&cfg.hub)?,
14220 requested: resolved_brain.clone(),
14221 brain: resolved_brain.clone(),
14222 home: None,
14223 anchor: anchor.clone(),
14224 current: format!("ed25519:{}", identity.fingerprint),
14225 head_seq: 0,
14226 feed_hash: None,
14227 rotations: identity.rotations.clone(),
14228 hub_signer: None,
14229 protocol_profile: None,
14230 },
14231 alias_binding.as_ref(),
14232 )?;
14233 return Ok(VerifiedRemote {
14234 head: Head {
14235 brain: resolved_brain,
14236 seq,
14237 updated_at,
14238 feed_hash: None,
14239 verified: true,
14240 },
14241 identity: Some(identity),
14242 head_entry: None,
14243 entries: Vec::new(),
14244 anchor: Some(anchor),
14245 });
14246 }
14247 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
14248 return Err(invalid_feed(
14249 "non-empty feed did not advertise a valid SHA-256 head",
14250 ));
14251 }
14252
14253 let replay_head_only = !require_full_chain
14257 && pinned
14258 .as_ref()
14259 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
14260 let mut after = if replay_head_only {
14261 seq - 1
14262 } else if require_full_chain || pinned.is_none() {
14263 0
14264 } else {
14265 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
14266 };
14267 let mut expected_seq = after + 1;
14268 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
14269 None
14270 } else {
14271 pinned
14272 .as_ref()
14273 .and_then(|checkpoint| checkpoint.feed_hash.clone())
14274 };
14275 let mut identity: Option<FeedIdentity> = None;
14276 let mut anchor: Option<String> = None;
14277 let mut head_entry: Option<FeedItem> = None;
14278 let mut all_entries = Vec::new();
14279 let mut observed_entries = Vec::new();
14280 let replay_count = seq
14281 .checked_sub(after)
14282 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
14283 if replay_count > MAX_FEED_REPLAY_ENTRIES {
14284 return Err(invalid_feed(format!(
14285 "feed replay requires {replay_count} entries, over the client cap"
14286 )));
14287 }
14288 let mut replay_bytes = 0u64;
14289
14290 loop {
14291 let feed_bytes = ensure_raw_ok(
14292 request_raw(
14293 cfg,
14294 "GET",
14295 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
14296 None,
14297 Auth::Required,
14298 MAX_FEED_RESPONSE_BYTES,
14299 )?,
14300 "subscribe feed",
14301 )?;
14302 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
14303 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
14304 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
14305 return Err(invalid_feed("brain card and feed head disagree"));
14306 }
14307 if feed.entries.len() > FEED_PAGE_LIMIT {
14308 return Err(invalid_feed("feed page exceeds the requested entry limit"));
14309 }
14310 if feed.scope_limited {
14311 if require_full_chain {
14312 return Err(invalid_feed(
14313 "path-scoped grants cannot verify a full snapshot chain",
14314 ));
14315 }
14316 return Ok(VerifiedRemote {
14317 head: Head {
14318 brain: resolved_brain,
14319 seq,
14320 updated_at,
14321 feed_hash: advertised_hash,
14322 verified: false,
14323 },
14324 identity: None,
14325 head_entry: None,
14326 entries: Vec::new(),
14327 anchor: None,
14328 });
14329 }
14330 let page_identity = feed
14331 .identity
14332 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14333 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
14334 if identity
14335 .as_ref()
14336 .is_some_and(|existing| existing != &page_identity)
14337 {
14338 return Err(invalid_feed("identity changed while reading the feed"));
14339 }
14340 if anchor
14341 .as_ref()
14342 .is_some_and(|existing| existing != &page_anchor)
14343 {
14344 return Err(invalid_feed(
14345 "identity anchor changed while reading the feed",
14346 ));
14347 }
14348 identity = Some(page_identity.clone());
14349 if anchor.is_none() {
14350 anchor = Some(page_anchor);
14351 }
14352 if feed.entries.is_empty() {
14353 return Err(invalid_feed("feed page was empty before the signed head"));
14354 }
14355
14356 for item in feed.entries {
14357 if item.entry.seq != expected_seq {
14358 return Err(invalid_feed(format!(
14359 "expected entry {expected_seq}, feed served {}",
14360 item.entry.seq
14361 )));
14362 }
14363 if item.entry.seq > seq {
14364 return Err(invalid_feed("feed advanced past the card snapshot"));
14365 }
14366 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
14367 return Err(invalid_feed(format!(
14368 "entry {} does not chain to the local checkpoint",
14369 item.entry.seq
14370 )));
14371 }
14372 verify_feed_item(&item, &page_identity)?;
14373 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
14374 replay_bytes = replay_bytes.saturating_add(
14375 serde_json::to_vec(&item)
14376 .map_err(|_| invalid_feed("could not size feed entry"))?
14377 .len() as u64,
14378 );
14379 if replay_bytes > MAX_FEED_REPLAY_BYTES {
14380 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
14381 }
14382 previous_hash = Some(item.hash.clone());
14383 after = item.entry.seq;
14384 expected_seq = expected_seq
14385 .checked_add(1)
14386 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
14387 if require_full_chain {
14388 all_entries.push(item.clone());
14389 }
14390 observed_entries.push(item.clone());
14391 head_entry = Some(item);
14392 }
14393 if after == seq {
14394 break;
14395 }
14396 }
14397
14398 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
14399 return Err(invalid_feed(
14400 "verified chain does not converge on the advertised head",
14401 ));
14402 }
14403 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14404 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
14405 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
14406 save_canonical_pin_and_alias(
14407 cfg,
14408 &trust_directory,
14409 brain,
14410 &resolved_brain,
14411 TrustState {
14412 v: 2,
14413 origin: normalized_origin(&cfg.hub)?,
14414 requested: resolved_brain.clone(),
14415 brain: resolved_brain.clone(),
14416 home: None,
14417 anchor: anchor.clone(),
14418 current: format!("ed25519:{}", identity.fingerprint),
14419 head_seq: seq,
14420 feed_hash: advertised_hash.clone(),
14421 rotations: identity.rotations.clone(),
14422 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
14423 protocol_profile: pinned
14424 .as_ref()
14425 .and_then(|state| state.protocol_profile.clone()),
14426 },
14427 alias_binding.as_ref(),
14428 )?;
14429 Ok(VerifiedRemote {
14430 head: Head {
14431 brain: resolved_brain,
14432 seq,
14433 updated_at,
14434 feed_hash: advertised_hash,
14435 verified: true,
14436 },
14437 identity: Some(identity),
14438 head_entry,
14439 entries: all_entries,
14440 anchor: Some(anchor),
14441 })
14442}
14443
14444pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
14449 if let Some(verified) = v2_verified_head(cfg, brain)? {
14450 let observation = Head {
14451 brain: verified.brain_id.clone(),
14452 seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14453 updated_at: verified
14454 .pointer
14455 .as_ref()
14456 .map(|pointer| pointer.signed_at.clone()),
14457 feed_hash: verified
14458 .pointer
14459 .as_ref()
14460 .map(|pointer| pointer.feed_hash.clone()),
14461 verified: true,
14462 };
14463 accept_v2_head(cfg, &verified)?;
14464 return Ok(observation);
14465 }
14466 Ok(verified_remote_head(cfg, brain, false)?.head)
14467}
14468
14469#[cfg(test)]
14470mod tests {
14471 use super::*;
14472
14473 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
14474
14475 fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
14476 json!({
14477 "sha256": "a".repeat(64),
14478 "bytes": 10,
14479 "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
14480 })
14481 }
14482
14483 #[test]
14484 fn upload_reservations_batch_by_count_and_by_size() {
14485 let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
14489 let batches = batch_upload_declarations(declarations.clone());
14490
14491 assert!(batches.len() > 1, "5,000 blobs must not ride one request");
14492 for batch in &batches {
14493 assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
14494 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14495 .expect("batch serializes")
14496 .len();
14497 assert!(
14498 bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
14499 "batch body {bytes} exceeds the reservation budget"
14500 );
14501 }
14502 let flattened: Vec<Value> = batches.into_iter().flatten().collect();
14503 assert_eq!(
14504 flattened, declarations,
14505 "batching must preserve the set and order"
14506 );
14507 }
14508
14509 #[test]
14510 fn only_load_shaped_hub_answers_are_worth_asking_again() {
14511 for status in [408, 429, 500, 502, 503, 504] {
14516 assert!(is_retryable_hub_status(status), "{status} is load-shaped");
14517 }
14518 for status in [400, 401, 403, 404, 409, 413, 422] {
14519 assert!(
14520 !is_retryable_hub_status(status),
14521 "{status} states something about the request"
14522 );
14523 }
14524 let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
14526 assert!(total >= 60_000, "backoff totals only {total}ms");
14527 }
14528
14529 #[test]
14530 fn a_batch_shares_a_connection_only_within_one_authority() {
14531 let cfg = HubConfig {
14536 hub: "https://www.sevrahq.com".to_string(),
14537 key: Some("k".to_string()),
14538 agent_key: None,
14539 brain_key: None,
14540 state_dir: PathBuf::from("."),
14541 store_selected: false,
14542 };
14543 assert!(shared_staging_agent(&cfg, &[]).is_none());
14544 assert!(
14545 shared_staging_agent(
14546 &cfg,
14547 &[
14548 "https://one.example.com/a?sig=1",
14549 "https://two.example.com/b?sig=2",
14550 ]
14551 )
14552 .is_none(),
14553 "two authorities must not share a pinned pool"
14554 );
14555 assert!(
14556 shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
14557 "an unsafe object-store URL must not produce an agent"
14558 );
14559 assert!(
14560 shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
14561 "credentials in the URL must not produce an agent"
14562 );
14563 }
14564
14565 #[test]
14566 fn a_staged_change_states_only_operations_and_blobs() {
14567 let operations = vec![json!({
14571 "op": "put",
14572 "path": "records/a.md",
14573 "blob": "a".repeat(64),
14574 "bytes": 3,
14575 })];
14576 let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
14577 let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
14578 let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
14579 let keys: Vec<&str> = parsed
14580 .as_object()
14581 .expect("manifest is an object")
14582 .keys()
14583 .map(String::as_str)
14584 .collect();
14585 assert_eq!(keys, ["blobs", "operations"]);
14586 assert_eq!(parsed["operations"], Value::Array(operations));
14587 assert_eq!(parsed["blobs"], blobs);
14588 }
14589
14590 #[test]
14591 fn a_staged_push_signs_the_change_not_the_transport() {
14592 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14597 let staged = json!({
14598 "mutation_id": "dbmd-1",
14599 "rebase": "strict",
14600 "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
14601 });
14602 let view = v2_signed_request_view(&staged, &operations);
14603 assert_eq!(view["operations"], Value::Array(operations.clone()));
14604 assert!(view.get("staged_change").is_none());
14605 assert_eq!(view["mutation_id"], staged["mutation_id"]);
14606
14607 let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
14608 assert_eq!(v2_signed_request_view(&inline, &[]), inline);
14609 }
14610
14611 #[test]
14612 fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
14613 let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
14614 let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
14615 .expect_err("an oversized change must not be staged");
14616 assert!(
14617 matches!(error, LinkError::PushTooLarge { .. }),
14618 "expected a size refusal, got {error:?}"
14619 );
14620 }
14621
14622 #[test]
14623 fn a_push_that_fits_the_request_is_left_inline() {
14624 let cfg = HubConfig {
14628 hub: "http://127.0.0.1:9".to_string(),
14629 key: Some("k".to_string()),
14630 agent_key: None,
14631 brain_key: None,
14632 state_dir: PathBuf::from("."),
14633 store_selected: false,
14634 };
14635 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14636 let mut body = json!({
14637 "mutation_id": "dbmd-1",
14638 "operations": operations,
14639 "blobs": [],
14640 });
14641 stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
14642 assert!(body.get("staged_change").is_none());
14643 assert_eq!(body["operations"], Value::Array(operations));
14644 }
14645
14646 #[test]
14647 fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
14648 let declarations: Vec<Value> = (0..2_000)
14652 .map(|index| {
14653 json!({
14654 "sha256": "a".repeat(64),
14655 "bytes": 10,
14656 "coordinates": (0..24)
14657 .map(|slot| format!(
14658 "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
14659 ))
14660 .collect::<Vec<_>>(),
14661 })
14662 })
14663 .collect();
14664 let batches = batch_upload_declarations(declarations);
14665 assert!(
14666 batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
14667 "wide coordinate sets must bound the batch by size"
14668 );
14669 for batch in &batches {
14670 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14671 .expect("batch serializes")
14672 .len();
14673 assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
14674 }
14675 }
14676
14677 #[test]
14678 fn a_small_push_still_rides_exactly_one_request() {
14679 let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
14680 assert_eq!(batch_upload_declarations(declarations).len(), 1);
14681 assert!(batch_upload_declarations(Vec::new()).is_empty());
14682 }
14683
14684 #[test]
14685 fn exact_source_move_becomes_one_provenance_preserving_rename() {
14686 let hash = "a".repeat(64);
14687 let operations = vec![
14688 json!({
14689 "op": "put",
14690 "path": "sources/curated/item.md",
14691 "expected": { "kind": "absent" },
14692 "blob": hash,
14693 "bytes": 19,
14694 }),
14695 json!({
14696 "op": "delete",
14697 "path": "sources/inbox/item.md",
14698 "expected": { "kind": "blob", "hash": hash },
14699 }),
14700 ];
14701
14702 assert_eq!(
14703 infer_exact_source_promotions(operations),
14704 vec![json!({
14705 "op": "rename",
14706 "from": "sources/inbox/item.md",
14707 "to": "sources/curated/item.md",
14708 "expected_from": { "kind": "blob", "hash": hash },
14709 "expected_to": { "kind": "absent" },
14710 "blob": hash,
14711 "bytes": 19,
14712 })]
14713 );
14714 }
14715
14716 #[test]
14717 fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
14718 let hash = "b".repeat(64);
14719 let operations = vec![
14720 json!({
14721 "op": "delete",
14722 "path": "sources/inbox/a.md",
14723 "expected": { "kind": "blob", "hash": hash },
14724 }),
14725 json!({
14726 "op": "delete",
14727 "path": "sources/inbox/b.md",
14728 "expected": { "kind": "blob", "hash": hash },
14729 }),
14730 json!({
14731 "op": "put",
14732 "path": "sources/curated/item.md",
14733 "expected": { "kind": "absent" },
14734 "blob": hash,
14735 "bytes": 19,
14736 }),
14737 ];
14738
14739 assert_eq!(
14740 infer_exact_source_promotions(operations.clone()),
14741 operations,
14742 "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
14743 );
14744 }
14745
14746 #[test]
14747 fn accepted_source_promotion_advances_the_local_baseline_exactly() {
14748 let hash = "c".repeat(64);
14749 let mut candidate = std::collections::BTreeMap::from([(
14750 "sources/inbox/item.md".to_string(),
14751 V2BaselineFile {
14752 sha256: hash.clone(),
14753 bytes: 19,
14754 proof: None,
14755 },
14756 )]);
14757 let mut candidate_assets = std::collections::BTreeMap::new();
14758 let operations = vec![
14759 json!({
14760 "op": "rename",
14761 "from": "sources/inbox/item.md",
14762 "to": "sources/curated/item.md",
14763 "expected_from": { "kind": "blob", "hash": hash },
14764 "expected_to": { "kind": "absent" },
14765 "blob": hash,
14766 "bytes": 19,
14767 }),
14768 json!({
14769 "op": "put",
14770 "path": "records/rsvps/item.md",
14771 "expected": { "kind": "absent" },
14772 "blob": "d".repeat(64),
14773 "bytes": 23,
14774 }),
14775 ];
14776
14777 assert!(!apply_generated_v2_operations(
14778 &operations,
14779 &std::collections::BTreeMap::new(),
14780 &mut candidate,
14781 &mut candidate_assets,
14782 )
14783 .unwrap());
14784 assert!(!candidate.contains_key("sources/inbox/item.md"));
14785 assert_eq!(
14786 candidate
14787 .get("sources/curated/item.md")
14788 .map(|file| (&file.sha256, file.bytes)),
14789 Some((&hash, 19))
14790 );
14791 assert_eq!(
14792 candidate
14793 .get("records/rsvps/item.md")
14794 .map(|file| (file.sha256.as_str(), file.bytes)),
14795 Some((
14796 "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
14797 23
14798 ))
14799 );
14800 }
14801
14802 fn merge_fixture(
14803 base: Option<&str>,
14804 remote: Option<&str>,
14805 local: Option<&str>,
14806 keep_local: bool,
14807 ) -> V2PulledMerge<String> {
14808 let map = |value: Option<&str>| {
14809 value
14810 .map(|value| [("records/a.md".to_string(), value.to_string())])
14811 .into_iter()
14812 .flatten()
14813 .collect::<std::collections::BTreeMap<_, _>>()
14814 };
14815 merge_v2_pulled_records(
14816 &map(base),
14817 &map(remote),
14818 &map(local),
14819 |value, _| value.clone(),
14820 |value, _| value.clone(),
14821 |_| keep_local,
14822 )
14823 }
14824
14825 #[test]
14826 fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
14827 let path = "records/a.md".to_string();
14828
14829 let local_add = merge_fixture(None, None, Some("local"), false);
14830 assert_eq!(
14831 local_add.records.get(&path).map(String::as_str),
14832 Some("local")
14833 );
14834 assert!(local_add.accept_remote.is_empty());
14835 assert!(local_add.conflicts.is_empty());
14836
14837 let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
14838 assert_eq!(
14839 local_edit.records.get(&path).map(String::as_str),
14840 Some("local")
14841 );
14842 assert!(local_edit.accept_remote.is_empty());
14843 assert!(local_edit.conflicts.is_empty());
14844
14845 let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
14846 assert!(!local_delete.records.contains_key(&path));
14847 assert!(local_delete.accept_remote.is_empty());
14848 assert!(local_delete.conflicts.is_empty());
14849
14850 let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
14851 assert_eq!(
14852 remote_edit.records.get(&path).map(String::as_str),
14853 Some("remote")
14854 );
14855 assert!(remote_edit.accept_remote.contains(&path));
14856 assert!(remote_edit.conflicts.is_empty());
14857
14858 let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
14859 assert!(!remote_delete.records.contains_key(&path));
14860 assert!(remote_delete.accept_remote.contains(&path));
14861 assert!(remote_delete.conflicts.is_empty());
14862
14863 let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
14864 assert_eq!(
14865 same_edit.records.get(&path).map(String::as_str),
14866 Some("same")
14867 );
14868 assert!(same_edit.accept_remote.contains(&path));
14869 assert!(same_edit.conflicts.is_empty());
14870
14871 let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
14872 assert_eq!(conflict.conflicts, vec![path.clone()]);
14873 assert_eq!(
14874 conflict.records.get(&path).map(String::as_str),
14875 Some("local")
14876 );
14877 assert!(conflict.accept_remote.is_empty());
14878
14879 let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
14880 assert_eq!(
14881 kept_home.records.get(&path).map(String::as_str),
14882 Some("local")
14883 );
14884 assert!(kept_home.accept_remote.is_empty());
14885 assert!(kept_home.conflicts.is_empty());
14886 }
14887
14888 #[test]
14889 fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
14890 let path = "sources/report.pdf";
14891 let record = crate::AssetRecord {
14892 path: path.to_string(),
14893 sha256: "a".repeat(64),
14894 bytes: 42,
14895 media_type: "application/pdf".to_string(),
14896 wrappers: vec!["gzip".to_string()],
14897 required: true,
14898 };
14899 let mut remote = V2BaselineAsset {
14900 blob_sha256: record.sha256.clone(),
14901 bytes: record.bytes,
14902 media_type: record.media_type.clone(),
14903 wrappers: record.wrappers.clone(),
14904 required: record.required,
14905 disposition: "withheld".to_string(),
14906 leaf_hash: "b".repeat(64),
14907 };
14908
14909 assert!(v2_asset_resumes_hosting(
14910 Some(&remote),
14911 path,
14912 &record,
14913 "hosted"
14914 ));
14915 assert!(!v2_asset_resumes_hosting(
14916 Some(&remote),
14917 path,
14918 &record,
14919 "withheld"
14920 ));
14921
14922 remote.disposition = "hosted".to_string();
14923 assert!(!v2_asset_resumes_hosting(
14924 Some(&remote),
14925 path,
14926 &record,
14927 "hosted"
14928 ));
14929
14930 remote.disposition = "withheld".to_string();
14931 remote.blob_sha256 = "c".repeat(64);
14932 assert!(!v2_asset_resumes_hosting(
14933 Some(&remote),
14934 path,
14935 &record,
14936 "hosted"
14937 ));
14938 assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
14939 }
14940
14941 #[test]
14942 fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
14943 let path = "records/team/alpha.md".to_string();
14944 let deleted_path = "records/team/deleted.md".to_string();
14945 let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
14946 sha256,
14947 bytes,
14948 file: None,
14949 };
14950 let files = vec![
14951 V2ConflictFile {
14952 path: path.clone(),
14953 base: coordinate(None, None),
14954 local: coordinate(Some("b".repeat(64)), Some(7)),
14955 remote: coordinate(Some("a".repeat(64)), Some(5)),
14956 },
14957 V2ConflictFile {
14958 path: deleted_path.clone(),
14959 base: coordinate(Some("c".repeat(64)), Some(9)),
14960 local: coordinate(Some("d".repeat(64)), Some(11)),
14961 remote: coordinate(None, None),
14962 },
14963 ];
14964 let proven = V2BaselineFile {
14965 sha256: "a".repeat(64),
14966 bytes: 5,
14967 proof: None,
14968 };
14969 let current = [(path.clone(), proven.clone())]
14970 .into_iter()
14971 .collect::<std::collections::BTreeMap<_, _>>();
14972
14973 let (selected, deleted) = v2_take_remote_selection(&files, ¤t).unwrap();
14974 assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
14975 assert_eq!(deleted, vec![deleted_path.clone()]);
14976
14977 let changed = [(
14978 path.clone(),
14979 V2BaselineFile {
14980 sha256: "e".repeat(64),
14981 bytes: 5,
14982 proof: None,
14983 },
14984 )]
14985 .into_iter()
14986 .collect::<std::collections::BTreeMap<_, _>>();
14987 assert!(v2_take_remote_selection(&files, &changed).is_err());
14988
14989 let resurrected = [
14990 (path, proven),
14991 (
14992 deleted_path,
14993 V2BaselineFile {
14994 sha256: "f".repeat(64),
14995 bytes: 13,
14996 proof: None,
14997 },
14998 ),
14999 ]
15000 .into_iter()
15001 .collect::<std::collections::BTreeMap<_, _>>();
15002 assert!(v2_take_remote_selection(&files, &resurrected).is_err());
15003 }
15004
15005 #[cfg(target_os = "linux")]
15006 #[test]
15007 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
15008 use std::os::fd::AsRawFd as _;
15009
15010 let sandbox = tempfile::TempDir::new().unwrap();
15011 let parent = std::fs::File::open(sandbox.path()).unwrap();
15012 let stage = std::ffi::CString::new("stage").unwrap();
15013 let destination = std::ffi::CString::new("brain").unwrap();
15014
15015 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15016 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
15017 install_stage_at(
15018 parent.as_raw_fd(),
15019 stage.as_c_str(),
15020 destination.as_c_str(),
15021 false,
15022 )
15023 .unwrap();
15024 assert!(!sandbox.path().join("stage").exists());
15025 assert_eq!(
15026 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15027 b"created"
15028 );
15029
15030 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15031 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
15032 install_stage_at(
15033 parent.as_raw_fd(),
15034 stage.as_c_str(),
15035 destination.as_c_str(),
15036 true,
15037 )
15038 .unwrap();
15039 assert_eq!(
15040 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15041 b"replacement"
15042 );
15043 assert_eq!(
15044 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
15045 b"created",
15046 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
15047 );
15048 }
15049
15050 struct SignedRemoteFixture {
15051 card: String,
15052 feed: String,
15053 key: AgentSigningKey,
15054 identity: FeedIdentity,
15055 }
15056
15057 fn signed_remote_fixture() -> SignedRemoteFixture {
15058 let rng = ring::rand::SystemRandom::new();
15059 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15060 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15061 let (public_key, multikey) = public_identity_for(&pair);
15062 let identity = FeedIdentity {
15063 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15064 public_key_spki: public_key.clone(),
15065 previous: Vec::new(),
15066 rotations: Vec::new(),
15067 };
15068 let mut entry = FeedEntry {
15069 v: 1,
15070 seq: 1,
15071 ts: "2026-07-30T12:00:00.000Z".to_string(),
15072 brain: multikey.clone(),
15073 public_key: public_key.clone(),
15074 kind: "push".to_string(),
15075 op: "snapshot".to_string(),
15076 pack_sha256: "a".repeat(64),
15077 files: Vec::new(),
15078 removed: Vec::new(),
15079 prev_entry_hash: None,
15080 sig: String::new(),
15081 };
15082 let unsigned = UnsignedFeedEntry {
15083 v: entry.v,
15084 seq: entry.seq,
15085 ts: &entry.ts,
15086 brain: &entry.brain,
15087 public_key: &entry.public_key,
15088 kind: &entry.kind,
15089 op: &entry.op,
15090 pack_sha256: &entry.pack_sha256,
15091 files: &entry.files,
15092 removed: &entry.removed,
15093 prev_entry_hash: &entry.prev_entry_hash,
15094 };
15095 entry.sig =
15096 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15097 let mut exact = serde_json::to_vec(&entry).unwrap();
15098 exact.push(b'\n');
15099 let hash = content_sha256(&exact);
15100 let card = json!({
15101 "id": TEST_BRAIN_ID,
15102 "headSeq": 1,
15103 "feedHash": hash,
15104 "identity": identity.clone(),
15105 })
15106 .to_string();
15107 let feed = json!({
15108 "headSeq": 1,
15109 "feedHash": hash,
15110 "identity": identity.clone(),
15111 "entries": [{"hash": hash, "entry": entry}],
15112 "scopeLimited": false,
15113 })
15114 .to_string();
15115 SignedRemoteFixture {
15116 card,
15117 feed,
15118 key: AgentSigningKey {
15119 pkcs8: pkcs8.as_ref().to_vec(),
15120 multikey,
15121 public_key_spki: public_key,
15122 },
15123 identity,
15124 }
15125 }
15126
15127 #[test]
15128 fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
15129 let file = |path: &str, byte: char| FeedFile {
15130 path: path.to_string(),
15131 sha256: byte.to_string().repeat(64),
15132 bytes: 1,
15133 };
15134 let a0 = file("records/a.md", 'a');
15135 let a1 = file("records/a.md", 'b');
15136 let stable = file("records/stable.md", 'c');
15137 let added = file("records/added.md", 'd');
15138 let removed_file = file("records/removed.md", 'e');
15139 let previous = vec![a0, stable.clone(), removed_file.clone()];
15140 let resulting = vec![a1.clone(), stable.clone(), added.clone()];
15141 let removed = vec![removed_file.path.clone()];
15142
15143 assert_eq!(
15144 verify_v1_manifest_disclosure(
15145 "edit",
15146 &previous,
15147 &resulting,
15148 &[a1.clone(), added.clone()],
15149 &removed,
15150 ),
15151 Ok(())
15152 );
15153 assert_eq!(
15154 verify_v1_manifest_disclosure(
15155 "edit",
15156 &previous,
15157 &resulting,
15158 &[stable.clone(), added.clone(), a1.clone()],
15159 &removed,
15160 ),
15161 Ok(())
15162 );
15163 assert_eq!(
15164 verify_v1_manifest_disclosure(
15165 "edit",
15166 &previous,
15167 &resulting,
15168 std::slice::from_ref(&added),
15169 &removed,
15170 ),
15171 Err(V1DisclosureError::EditMissingChange)
15172 );
15173 assert_eq!(
15174 verify_v1_manifest_disclosure(
15175 "edit",
15176 &previous,
15177 &resulting,
15178 &[file("records/a.md", 'f'), added.clone()],
15179 &removed,
15180 ),
15181 Err(V1DisclosureError::EditFalseFile)
15182 );
15183 assert_eq!(
15184 verify_v1_manifest_disclosure(
15185 "edit",
15186 &previous,
15187 &resulting,
15188 &[a1.clone(), added.clone()],
15189 &[],
15190 ),
15191 Err(V1DisclosureError::RemovedMismatch)
15192 );
15193 assert_eq!(
15194 verify_v1_manifest_disclosure(
15195 "push",
15196 &previous,
15197 &resulting,
15198 &[added.clone(), stable, a1],
15199 &removed,
15200 ),
15201 Ok(())
15202 );
15203 assert_eq!(
15204 verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
15205 Err(V1DisclosureError::PushManifestMismatch)
15206 );
15207 }
15208
15209 #[test]
15210 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
15211 let fixture = signed_remote_fixture();
15212 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
15213 let item = feed["entries"][0].to_string();
15214 let oversized_page = format!(
15215 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
15216 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
15217 .collect::<Vec<_>>()
15218 .join(",")
15219 );
15220 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
15221
15222 let oversized_identity = format!(
15223 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
15224 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
15225 .collect::<Vec<_>>()
15226 .join(",")
15227 );
15228 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
15229
15230 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
15231 let oversized_entry = format!(
15232 "{{\"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\"}}",
15233 "a".repeat(64),
15234 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
15235 .collect::<Vec<_>>()
15236 .join(",")
15237 );
15238 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
15239 }
15240
15241 #[test]
15242 fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
15243 let id = "01arz3ndektsv4rrffq69g5fav";
15244 let digest = "a".repeat(64);
15245 assert_eq!(
15246 V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
15247 V2BulkConfirmation {
15248 id: id.to_string(),
15249 digest,
15250 }
15251 );
15252 for invalid in [
15253 "",
15254 "01arz3ndektsv4rrffq69g5fav",
15255 "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15256 "01arz3ndektsv4rrffq69g5fav:ABCDEF",
15257 "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15258 ] {
15259 assert!(matches!(
15260 V2BulkConfirmation::parse(invalid),
15261 Err(LinkError::InvalidPack { .. })
15262 ));
15263 }
15264 }
15265
15266 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
15267 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15268 use std::net::TcpListener;
15269
15270 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15271 let url = format!("http://{}", listener.local_addr().unwrap());
15272 let handle = std::thread::spawn(move || {
15273 for (status, body) in responses {
15274 let (stream, _) = listener.accept().unwrap();
15275 let mut reader = BufReader::new(stream);
15276 let mut line = String::new();
15277 reader.read_line(&mut line).unwrap();
15278 let mut content_length = 0usize;
15279 loop {
15280 line.clear();
15281 reader.read_line(&mut line).unwrap();
15282 if line == "\r\n" || line == "\n" || line.is_empty() {
15283 break;
15284 }
15285 if let Some((name, value)) = line.split_once(':') {
15286 if name.eq_ignore_ascii_case("content-length") {
15287 content_length = value.trim().parse().unwrap();
15288 }
15289 }
15290 }
15291 let mut request_body = vec![0_u8; content_length];
15292 reader.read_exact(&mut request_body).unwrap();
15293 let response = format!(
15294 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15295 body.len()
15296 );
15297 reader.get_mut().write_all(response.as_bytes()).unwrap();
15298 }
15299 });
15300 (url, handle)
15301 }
15302
15303 fn routed_json_hub(
15304 requests: usize,
15305 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
15306 ) -> (String, std::thread::JoinHandle<()>) {
15307 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15308 use std::net::TcpListener;
15309
15310 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15311 let url = format!("http://{}", listener.local_addr().unwrap());
15312 let handle = std::thread::spawn(move || {
15313 for _ in 0..requests {
15314 let (stream, _) = listener.accept().unwrap();
15315 let mut reader = BufReader::new(stream);
15316 let mut line = String::new();
15317 reader.read_line(&mut line).unwrap();
15318 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
15319 let mut content_length = 0usize;
15320 loop {
15321 line.clear();
15322 reader.read_line(&mut line).unwrap();
15323 if line == "\r\n" || line == "\n" || line.is_empty() {
15324 break;
15325 }
15326 if let Some((name, value)) = line.split_once(':') {
15327 if name.eq_ignore_ascii_case("content-length") {
15328 content_length = value.trim().parse().unwrap();
15329 }
15330 }
15331 }
15332 let mut request_body = vec![0_u8; content_length];
15333 reader.read_exact(&mut request_body).unwrap();
15334 let (status, body) = respond(&path);
15335 let response = format!(
15336 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15337 body.len()
15338 );
15339 reader.get_mut().write_all(response.as_bytes()).unwrap();
15340 }
15341 });
15342 (url, handle)
15343 }
15344
15345 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
15346 HubConfig {
15347 hub,
15348 key: Some("test-key".to_string()),
15349 agent_key: None,
15350 brain_key: None,
15351 state_dir,
15352 store_selected: false,
15353 }
15354 }
15355
15356 #[test]
15357 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
15358 use ring::signature::KeyPair as _;
15359
15360 let rng = ring::rand::SystemRandom::new();
15361 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15362 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15363 let (spki, multikey) = public_identity_for(&pair);
15364 let key = AgentSigningKey {
15365 pkcs8: pkcs8.as_ref().to_vec(),
15366 multikey,
15367 public_key_spki: spki,
15368 };
15369 let header = linkmd_sig_header(
15370 &key,
15371 "https://hub-a.example",
15372 "post",
15373 "/api/hub/brains/brain/push?mode=exact",
15374 Some("{\"ok\":true}"),
15375 )
15376 .unwrap();
15377 assert!(header.starts_with("LinkMD-Sig v2,"));
15378 let ts = header
15379 .split(",ts=")
15380 .nth(1)
15381 .unwrap()
15382 .split(',')
15383 .next()
15384 .unwrap();
15385 let signature = URL_SAFE_NO_PAD
15386 .decode(header.rsplit(",sig=").next().unwrap())
15387 .unwrap();
15388 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
15389 let accepted = format!(
15390 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15391 );
15392 let replayed = format!(
15393 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15394 );
15395 let public = pair.public_key().as_ref();
15396 assert!(UnparsedPublicKey::new(&ED25519, public)
15397 .verify(accepted.as_bytes(), &signature)
15398 .is_ok());
15399 assert!(
15400 UnparsedPublicKey::new(&ED25519, public)
15401 .verify(replayed.as_bytes(), &signature)
15402 .is_err(),
15403 "a proof captured at hub A must not authenticate at hub B"
15404 );
15405 }
15406
15407 #[test]
15408 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
15409 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15410 let card = json!({
15411 "id": other,
15412 "headSeq": 0,
15413 "identity": signed_remote_fixture().identity,
15414 })
15415 .to_string();
15416 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15417 let state = tempfile::tempdir().unwrap();
15418 let cfg = test_hub_config(hub, state.path().to_path_buf());
15419 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15420 assert!(
15421 error.contains("differs from the explicitly requested"),
15422 "{error}"
15423 );
15424 server.join().unwrap();
15425 }
15426
15427 #[test]
15428 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
15429 let first = signed_remote_fixture().identity;
15430 let second = signed_remote_fixture().identity;
15431 let card = |identity: FeedIdentity| {
15432 json!({
15433 "id": TEST_BRAIN_ID,
15434 "headSeq": 0,
15435 "identity": identity,
15436 })
15437 .to_string()
15438 };
15439 let (hub, server) = scripted_json_hub(vec![
15440 (404, "{}".to_string()),
15441 (200, card(first)),
15442 (404, "{}".to_string()),
15443 (200, card(second)),
15444 ]);
15445 let state = tempfile::tempdir().unwrap();
15446 let cfg = test_hub_config(hub, state.path().to_path_buf());
15447 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15448 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15449 assert!(
15450 error.contains("pinned anchor") || error.contains("forked away"),
15451 "{error}"
15452 );
15453 server.join().unwrap();
15454 }
15455
15456 #[test]
15457 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
15458 let old = signed_remote_fixture();
15459 let new = signed_remote_fixture();
15460 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
15461 let unsigned = serde_json::to_string(&UnsignedRotation {
15462 v: 1,
15463 op: "rotate",
15464 brain: &old.key.multikey,
15465 public_key: &old.key.public_key_spki,
15466 new_brain: &new.key.multikey,
15467 new_public_key: &new.key.public_key_spki,
15468 prior_head_seq: 1,
15469 prior_feed_hash: Some(&"a".repeat(64)),
15470 ts: "2026-07-30T12:00:00.000Z".to_string(),
15471 })
15472 .unwrap();
15473 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
15474 let rotation = format!(
15475 "{},\"sig\":\"{}\"}}",
15476 &unsigned[..unsigned.len() - 1],
15477 signature
15478 );
15479 let identity = FeedIdentity {
15480 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
15481 public_key_spki: new.key.public_key_spki,
15482 previous: vec![PreviousIdentity {
15483 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
15484 public_key_spki: old.key.public_key_spki,
15485 }],
15486 rotations: vec![rotation],
15487 };
15488 let card = json!({
15489 "id": TEST_BRAIN_ID,
15490 "headSeq": 0,
15491 "feedHash": null,
15492 "identity": identity,
15493 })
15494 .to_string();
15495 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15496 let state = tempfile::tempdir().unwrap();
15497 let cfg = test_hub_config(hub, state.path().to_path_buf());
15498 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15499 assert!(
15500 error.contains("rotation claims a feed boundary beyond the advertised head"),
15501 "{error}"
15502 );
15503 assert!(
15504 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
15505 "an inconsistent empty-head identity must not become the TOFU checkpoint"
15506 );
15507 server.join().unwrap();
15508 }
15509
15510 #[test]
15511 fn trust_checkpoint_rejects_a_later_fork() {
15512 let fixture = signed_remote_fixture();
15513 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
15514 fork["feedHash"] = Value::String("b".repeat(64));
15515 let (hub, server) = scripted_json_hub(vec![
15516 (404, "{}".to_string()),
15517 (200, fixture.card),
15518 (200, fixture.feed),
15519 (404, "{}".to_string()),
15520 (200, fork.to_string()),
15521 ]);
15522 let state = tempfile::tempdir().unwrap();
15523 let cfg = test_hub_config(hub, state.path().to_path_buf());
15524 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15525 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
15526 server.join().unwrap();
15527 }
15528
15529 #[test]
15530 fn alias_and_canonical_id_share_one_identity_checkpoint() {
15531 let trusted = signed_remote_fixture();
15532 let attacker = signed_remote_fixture();
15533 let (hub, server) = scripted_json_hub(vec![
15534 (404, "{}".to_string()),
15535 (200, trusted.card),
15536 (200, trusted.feed),
15537 (404, "{}".to_string()),
15538 (200, attacker.card),
15539 ]);
15540 let state = tempfile::tempdir().unwrap();
15541 let cfg = test_hub_config(hub, state.path().to_path_buf());
15542 assert!(head(&cfg, "trusted-slug").unwrap().verified);
15543 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15544 assert!(
15545 error.contains("equivocation")
15546 || error.contains("pinned")
15547 || error.contains("identity"),
15548 "{error}"
15549 );
15550 server.join().unwrap();
15551 }
15552
15553 #[test]
15554 fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
15555 let state = tempfile::tempdir().unwrap();
15556 let cfg = test_hub_config(
15557 "https://hub.example".to_string(),
15558 state.path().to_path_buf(),
15559 );
15560 let directory = open_trust_dir(&cfg).unwrap();
15561 let old = TEST_BRAIN_ID;
15562 let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15563 save_alias_in(
15564 &cfg,
15565 &directory,
15566 &AliasBinding {
15567 v: 1,
15568 origin: normalized_origin(&cfg.hub).unwrap(),
15569 requested: "company-brain".to_string(),
15570 brain: old.to_string(),
15571 home: Some("company-brain".to_string()),
15572 },
15573 )
15574 .unwrap();
15575
15576 let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
15577 assert!(matches!(
15578 error,
15579 LinkError::AliasRebindRequired {
15580 alias,
15581 from,
15582 to
15583 } if alias == "company-brain" && from == old && to == new
15584 ));
15585 let unchanged = load_alias_in(&cfg, &directory, "company-brain")
15586 .unwrap()
15587 .unwrap();
15588 assert_eq!(unchanged.brain, old);
15589 assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
15590 }
15591
15592 #[test]
15593 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
15594 let alpha = signed_remote_fixture();
15595 let beta = signed_remote_fixture();
15596 let alpha_card = alpha.card.clone();
15597 let alpha_feed = alpha.feed.clone();
15598 let beta_card = beta.card.clone();
15599 let beta_feed = beta.feed.clone();
15600 let (hub, server) = routed_json_hub(5, move |path| {
15601 if path.ends_with("/v2/head") {
15602 (404, "{}".to_string())
15603 } else if path.contains("/alpha/feed?") {
15604 (200, alpha_feed.clone())
15605 } else if path.contains("/beta/feed?") {
15606 (200, beta_feed.clone())
15607 } else if path.ends_with("/alpha") {
15608 (200, alpha_card.clone())
15609 } else if path.ends_with("/beta") {
15610 (200, beta_card.clone())
15611 } else {
15612 (500, r#"{"error":"unexpected path"}"#.to_string())
15613 }
15614 });
15615 let state = tempfile::tempdir().unwrap();
15616 let cfg = test_hub_config(hub, state.path().to_path_buf());
15617 let alpha_cfg = cfg.clone();
15618 let beta_cfg = cfg;
15619 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
15620 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
15621 let results = [first.join().unwrap(), second.join().unwrap()];
15622 assert_eq!(
15623 results.iter().filter(|result| result.is_ok()).count(),
15624 1,
15625 "only one alias identity may establish canonical TOFU: {results:?}"
15626 );
15627 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
15628 server.join().unwrap();
15629 }
15630
15631 #[cfg(unix)]
15632 #[test]
15633 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
15634 use std::os::unix::fs::symlink;
15635
15636 let fixture = signed_remote_fixture();
15637 let card = json!({
15638 "id": TEST_BRAIN_ID,
15639 "headSeq": 0,
15640 "feedHash": Value::Null,
15641 "identity": fixture.identity,
15642 })
15643 .to_string();
15644 let work = tempfile::tempdir().unwrap();
15645 let outside = tempfile::tempdir().unwrap();
15646 let state = work.path().join("state");
15647 let moved = work.path().join("state-held");
15648 let swap_state = state.clone();
15649 let swap_moved = moved.clone();
15650 let outside_path = outside.path().to_path_buf();
15651 let (hub, server) = routed_json_hub(1, move |_| {
15652 std::fs::rename(&swap_state, &swap_moved).unwrap();
15654 symlink(&outside_path, &swap_state).unwrap();
15655 (200, card.clone())
15656 });
15657 let cfg = test_hub_config(hub, state);
15658
15659 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
15660 assert_eq!(verified.head.seq, 0);
15661 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
15662 assert!(std::fs::read_dir(moved.join("trust"))
15663 .unwrap()
15664 .flatten()
15665 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
15666 server.join().unwrap();
15667 }
15668
15669 #[test]
15670 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
15671 let remote = signed_remote_fixture();
15672 let unrelated = signed_remote_fixture().key;
15673 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
15674 let state = tempfile::tempdir().unwrap();
15675 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
15676 cfg.brain_key = Some(unrelated);
15677 let error = sync_push(
15678 &cfg,
15679 TEST_BRAIN_ID,
15680 &[("DB.md".to_string(), "signed local content".to_string())],
15681 )
15682 .unwrap_err()
15683 .to_string();
15684 assert!(
15685 error.contains("not the verified current brain identity"),
15686 "{error}"
15687 );
15688 server.join().unwrap();
15689 }
15690
15691 #[test]
15692 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
15693 let remote = signed_remote_fixture();
15694 let new = signed_remote_fixture().key;
15695 let state = tempfile::tempdir().unwrap();
15696 let new_file = state.path().join("new.key");
15697 std::fs::write(
15698 &new_file,
15699 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
15700 )
15701 .unwrap();
15702 #[cfg(unix)]
15703 {
15704 use std::os::unix::fs::PermissionsExt as _;
15705 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
15706 }
15707 let forged = json!({
15708 "brain": TEST_BRAIN_ID,
15709 "identity": {
15710 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
15711 "publicKeySpki": new.public_key_spki,
15712 }
15713 })
15714 .to_string();
15715 let (hub, server) = scripted_json_hub(vec![
15716 (404, "{}".to_string()),
15717 (200, remote.card.clone()),
15718 (200, remote.feed.clone()),
15719 (200, forged),
15720 (200, remote.card),
15721 (200, remote.feed),
15722 ]);
15723 let cfg = test_hub_config(hub, state.path().to_path_buf());
15724 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
15725 .unwrap_err()
15726 .to_string();
15727 assert!(
15728 error.contains("without committing the verified new identity"),
15729 "{error}"
15730 );
15731 server.join().unwrap();
15732 }
15733
15734 #[test]
15735 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
15736 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15737 let raw = format!(
15738 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
15739 );
15740 let pack = build_store_pack(&[
15741 (
15742 "DB.md".to_string(),
15743 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
15744 ),
15745 ("records/clients/truth.md".to_string(), raw.clone()),
15746 ])
15747 .unwrap();
15748 let by_id = resolve_from_verified_pack(
15749 "01j5qc3v9k4ym8rwbn2tqe6f7d",
15750 &AddressTarget::Id(record_id.to_string()),
15751 pack.clone(),
15752 )
15753 .unwrap();
15754 assert_eq!(by_id["document"]["summary"], "Signed truth");
15755 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
15756 assert_eq!(
15757 by_id["document"]["contentSha"],
15758 content_sha256(raw.as_bytes())
15759 );
15760
15761 let by_path = resolve_from_verified_pack(
15762 "01j5qc3v9k4ym8rwbn2tqe6f7d",
15763 &AddressTarget::Path("records/clients/truth.md".to_string()),
15764 pack,
15765 )
15766 .unwrap();
15767 assert_eq!(by_path["document"]["id"], record_id);
15768 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
15769
15770 let wrong_id = resolve_from_verified_record_bytes(
15771 TEST_BRAIN_ID,
15772 &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
15773 "records/clients/truth.md".to_string(),
15774 raw.as_bytes().to_vec(),
15775 )
15776 .unwrap_err()
15777 .to_string();
15778 assert!(wrong_id.contains("id differs"), "{wrong_id}");
15779
15780 let wrong_path = resolve_from_verified_record_bytes(
15781 TEST_BRAIN_ID,
15782 &AddressTarget::Path("records/clients/other.md".to_string()),
15783 "records/clients/truth.md".to_string(),
15784 raw.into_bytes(),
15785 )
15786 .unwrap_err()
15787 .to_string();
15788 assert!(wrong_path.contains("path differs"), "{wrong_path}");
15789 }
15790
15791 #[test]
15792 fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
15793 let path = "records/clients/truth.md";
15794 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15795 let raw = format!(
15796 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
15797 );
15798 let sha256 = content_sha256(raw.as_bytes());
15799 let mut nonce = 0_u128;
15800 let tree = crate::linkmd_v2::build_content_tree(
15801 &[crate::linkmd_v2::ContentFile {
15802 path: path.to_string(),
15803 blob_hash: sha256.clone(),
15804 bytes: raw.len() as u64,
15805 }],
15806 None,
15807 &mut || {
15808 nonce += 1;
15809 format!("{nonce:032x}")
15810 },
15811 )
15812 .unwrap();
15813 let root = tree.root.clone().unwrap();
15814 let mut directory_root = root.clone();
15815 let mut proof = Vec::new();
15816 for component in path.split('/') {
15817 let inclusion =
15818 crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
15819 let child = match &inclusion {
15820 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
15821 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
15822 panic!("fixture path must have an inclusion proof")
15823 }
15824 };
15825 proof.push(json!({
15826 "directory_root": directory_root,
15827 "component": component,
15828 "proof": inclusion,
15829 }));
15830 directory_root = child;
15831 }
15832 let commit_hash = "c".repeat(64);
15833 let pointer = V2PointerBody {
15834 v: 2,
15835 brain: TEST_BRAIN_ID.to_string(),
15836 seq: 1,
15837 commit_hash: commit_hash.clone(),
15838 feed_hash: "f".repeat(64),
15839 content_root: Some(root.clone()),
15840 asset_root: None,
15841 materializer: "dbmd-projection-v1".to_string(),
15842 signer_epoch: 1,
15843 control_revision: "d".repeat(64),
15844 backup_preparation: "e".repeat(64),
15845 prior_pointer_hash: None,
15846 signed_at: "2026-08-21T12:00:00.000Z".to_string(),
15847 };
15848 let manifest = json!({
15849 "v": 2,
15850 "commit": commit_hash,
15851 "content_root": root,
15852 "files": [{
15853 "path": path,
15854 "sha256": sha256,
15855 "bytes": raw.len(),
15856 "proof": proof,
15857 }],
15858 "next_cursor": Value::Null,
15859 })
15860 .to_string();
15861
15862 let path_manifest = manifest.clone();
15863 let (hub, server) = routed_json_hub(1, move |request| {
15864 assert_eq!(
15865 request,
15866 format!(
15867 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
15868 "c".repeat(64)
15869 )
15870 );
15871 (200, path_manifest.clone())
15872 });
15873 let state = tempfile::tempdir().unwrap();
15874 let cfg = test_hub_config(hub, state.path().to_path_buf());
15875 let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
15876 .unwrap()
15877 .unwrap();
15878 assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
15879 assert!(by_path.proof.is_some());
15880 server.join().unwrap();
15881
15882 let id_manifest = manifest;
15883 let (hub, server) = routed_json_hub(1, move |request| {
15884 assert_eq!(
15885 request,
15886 format!(
15887 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
15888 "c".repeat(64)
15889 )
15890 );
15891 (200, id_manifest.clone())
15892 });
15893 let state = tempfile::tempdir().unwrap();
15894 let cfg = test_hub_config(hub, state.path().to_path_buf());
15895 let (located_path, by_id) =
15896 v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
15897 assert_eq!(located_path, path);
15898 assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
15899 server.join().unwrap();
15900 }
15901
15902 #[test]
15903 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
15904 let unsorted = vec![
15905 ("records/a.md".to_string(), "alpha\n".to_string()),
15906 ("DB.md".to_string(), "# db\n".to_string()),
15907 ];
15908 let sorted = vec![
15909 ("DB.md".to_string(), "# db\n".to_string()),
15910 ("records/a.md".to_string(), "alpha\n".to_string()),
15911 ];
15912 let pack = build_store_pack(&unsorted).unwrap();
15913
15914 assert_eq!(pack.len(), 219);
15919 assert_eq!(
15920 content_sha256(&pack),
15921 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
15922 );
15923 assert_eq!(pack, build_store_pack(&sorted).unwrap());
15924 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
15925 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
15926 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
15927
15928 assert_eq!(
15929 parse_store_pack(pack).unwrap(),
15930 vec![
15931 ("DB.md".to_string(), b"# db\n".to_vec()),
15932 ("records/a.md".to_string(), b"alpha\n".to_vec()),
15933 ]
15934 );
15935 }
15936
15937 #[test]
15938 fn canonical_store_pack_validates_every_path_before_writing() {
15939 let duplicate = vec![
15940 ("DB.md".to_string(), "first".to_string()),
15941 ("DB.md".to_string(), "second".to_string()),
15942 ];
15943 assert!(build_store_pack(&duplicate)
15944 .unwrap_err()
15945 .to_string()
15946 .contains("duplicate path"));
15947 assert!(matches!(
15948 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
15949 Err(LinkError::UnsafePath { .. })
15950 ));
15951 }
15952
15953 #[test]
15954 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
15955 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
15956 let mut bytes = vec![0_u8];
15959 let zip64_offset = bytes.len() as u64;
15960 bytes.extend_from_slice(b"PK\x06\x06");
15961 bytes.extend_from_slice(&44_u64.to_le_bytes());
15962 bytes.extend_from_slice(&[0_u8; 12]);
15963 bytes.extend_from_slice(&COUNT.to_le_bytes());
15964 bytes.extend_from_slice(&COUNT.to_le_bytes());
15965 bytes.extend_from_slice(&1_u64.to_le_bytes());
15966 bytes.extend_from_slice(&0_u64.to_le_bytes());
15967 bytes.extend_from_slice(b"PK\x06\x07");
15968 bytes.extend_from_slice(&0_u32.to_le_bytes());
15969 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
15970 bytes.extend_from_slice(&1_u32.to_le_bytes());
15971 bytes.extend_from_slice(b"PK\x05\x06");
15972 bytes.extend_from_slice(&0_u16.to_le_bytes());
15973 bytes.extend_from_slice(&0_u16.to_le_bytes());
15974 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
15975 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
15976 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
15977 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
15978 bytes.extend_from_slice(&0_u16.to_le_bytes());
15979
15980 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
15981 .unwrap_err()
15982 .to_string();
15983 assert!(error.contains("invalid file count"), "{error}");
15984 }
15985
15986 #[test]
15987 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
15988 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
15989 let mut bytes = vec![0_u8];
15990 let zip64_offset = bytes.len() as u64;
15991 bytes.extend_from_slice(b"PK\x06\x06");
15992 bytes.extend_from_slice(&44_u64.to_le_bytes());
15993 bytes.extend_from_slice(&[0_u8; 12]);
15994 bytes.extend_from_slice(&COUNT.to_le_bytes());
15995 bytes.extend_from_slice(&COUNT.to_le_bytes());
15996 bytes.extend_from_slice(&1_u64.to_le_bytes());
15997 bytes.extend_from_slice(&0_u64.to_le_bytes());
15998 bytes.extend_from_slice(b"PK\x06\x07");
15999 bytes.extend_from_slice(&0_u32.to_le_bytes());
16000 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16001 bytes.extend_from_slice(&1_u32.to_le_bytes());
16002 bytes.extend_from_slice(b"PK\x05\x06");
16003 bytes.extend_from_slice(&0_u16.to_le_bytes());
16004 bytes.extend_from_slice(&0_u16.to_le_bytes());
16005 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16006 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16007 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16008 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16009 bytes.extend_from_slice(&0_u16.to_le_bytes());
16010 let fake_eocd = bytes.len() as u32;
16014 bytes.extend_from_slice(b"PK\x05\x06");
16015 bytes.extend_from_slice(&0_u16.to_le_bytes());
16016 bytes.extend_from_slice(&0_u16.to_le_bytes());
16017 bytes.extend_from_slice(&1_u16.to_le_bytes());
16018 bytes.extend_from_slice(&1_u16.to_le_bytes());
16019 bytes.extend_from_slice(&0_u32.to_le_bytes());
16020 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
16021 bytes.extend_from_slice(&0_u16.to_le_bytes());
16022
16023 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16024 .unwrap_err()
16025 .to_string();
16026 assert!(error.contains("central directory"), "{error}");
16027 }
16028
16029 #[test]
16030 fn strict_http_status_handling_rejects_redirects_without_panicking() {
16031 let error = ensure_ok(
16032 HubResponse {
16033 status: 302,
16034 body: Some(json!({"redirect": "/elsewhere"})),
16035 },
16036 "mutation",
16037 )
16038 .unwrap_err();
16039 assert!(matches!(error, LinkError::Http { status: 302, .. }));
16040
16041 let error = ensure_raw_ok(
16042 RawHubResponse {
16043 status: 302,
16044 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
16045 },
16046 "feed",
16047 )
16048 .unwrap_err();
16049 assert!(matches!(error, LinkError::Http { status: 302, .. }));
16050 }
16051
16052 #[cfg(unix)]
16053 #[test]
16054 fn collect_push_files_refuses_external_symlink_and_nested_store() {
16055 use std::os::unix::fs::symlink;
16056
16057 let root = tempfile::tempdir().unwrap();
16058 std::fs::write(
16059 root.path().join("DB.md"),
16060 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
16061 )
16062 .unwrap();
16063 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
16064
16065 let external = tempfile::tempdir().unwrap();
16066 let secret = external.path().join("secret.md");
16067 std::fs::write(&secret, "TOP SECRET").unwrap();
16068 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
16069
16070 let store = Store::open_strict(root.path()).unwrap();
16071 let err = collect_push_files(&store).unwrap_err().to_string();
16072 assert!(err.contains("cannot push"), "{err}");
16073 assert!(
16074 !err.contains("TOP SECRET"),
16075 "external bytes must never leak"
16076 );
16077
16078 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
16079 let nested = root.path().join("records/nested");
16080 std::fs::create_dir_all(&nested).unwrap();
16081 std::fs::write(
16082 nested.join("DB.md"),
16083 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
16084 )
16085 .unwrap();
16086 let err = collect_push_files(&store).unwrap_err().to_string();
16087 assert!(err.contains("nested db.md store"), "{err}");
16088 }
16089
16090 #[cfg(unix)]
16091 #[test]
16092 fn remote_push_uses_opened_root_after_path_replacement() {
16093 use std::os::unix::fs::symlink;
16094
16095 let sandbox = tempfile::tempdir().unwrap();
16096 let root = sandbox.path().join("store");
16097 std::fs::create_dir_all(root.join("records/notes")).unwrap();
16098 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16099 std::fs::write(
16100 root.join("records/notes/owned.md"),
16101 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
16102 )
16103 .unwrap();
16104 let store = Store::open_strict(&root).unwrap();
16105 let detached = sandbox.path().join("detached");
16106 std::fs::rename(&root, &detached).unwrap();
16107
16108 let replacement = sandbox.path().join("replacement");
16109 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
16110 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16111 std::fs::write(
16112 replacement.join("records/notes/secret.md"),
16113 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
16114 )
16115 .unwrap();
16116 symlink(&replacement, &root).unwrap();
16117
16118 let files = collect_push_files(&store).unwrap();
16119 let wire_text = files
16120 .iter()
16121 .map(|(path, content)| format!("{path}\n{content}"))
16122 .collect::<Vec<_>>()
16123 .join("\n");
16124 assert!(wire_text.contains("owned upload"));
16125 assert!(!wire_text.contains("replacement sentinel"));
16126 assert!(!wire_text.contains("records/notes/secret.md"));
16127
16128 let remote = signed_remote_fixture();
16129 let (hub, server) = scripted_json_hub(vec![
16130 (200, remote.card),
16131 (200, remote.feed),
16132 (200, json!({"ok": true}).to_string()),
16133 ]);
16134 let state = tempfile::tempdir().unwrap();
16135 let cfg = test_hub_config(hub, state.path().to_path_buf());
16136 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
16137 assert_eq!(pushed, json!({"ok": true}));
16138 server.join().unwrap();
16139 }
16140
16141 #[test]
16142 fn signed_feed_item_verifies_identity_hash_and_signature() {
16143 use ring::rand::SystemRandom;
16144 use ring::signature::{Ed25519KeyPair, KeyPair};
16145
16146 const PREFIX: &[u8] = &[
16147 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
16148 ];
16149 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
16150 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16151 let mut spki = PREFIX.to_vec();
16152 spki.extend_from_slice(pair.public_key().as_ref());
16153 let public_key = URL_SAFE_NO_PAD.encode(&spki);
16154 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
16155 let mut entry = FeedEntry {
16156 v: 1,
16157 seq: 1,
16158 ts: "2026-07-14T00:00:00.000Z".to_string(),
16159 brain: format!("ed25519:{fingerprint}"),
16160 public_key: public_key.clone(),
16161 kind: "push".to_string(),
16162 op: "snapshot".to_string(),
16163 pack_sha256: "a".repeat(64),
16164 files: vec![FeedFile {
16165 path: "DB.md".to_string(),
16166 sha256: "b".repeat(64),
16167 bytes: 3,
16168 }],
16169 removed: vec![],
16170 prev_entry_hash: None,
16171 sig: String::new(),
16172 };
16173 let unsigned = UnsignedFeedEntry {
16174 v: entry.v,
16175 seq: entry.seq,
16176 ts: &entry.ts,
16177 brain: &entry.brain,
16178 public_key: &entry.public_key,
16179 kind: &entry.kind,
16180 op: &entry.op,
16181 pack_sha256: &entry.pack_sha256,
16182 files: &entry.files,
16183 removed: &entry.removed,
16184 prev_entry_hash: &entry.prev_entry_hash,
16185 };
16186 entry.sig =
16187 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16188 let mut exact = serde_json::to_vec(&entry).unwrap();
16189 exact.push(b'\n');
16190 let item = FeedItem {
16191 hash: format!("{:x}", Sha256::digest(&exact)),
16192 entry,
16193 };
16194 let identity = FeedIdentity {
16195 fingerprint,
16196 public_key_spki: public_key,
16197 previous: Vec::new(),
16198 rotations: Vec::new(),
16199 };
16200 assert!(verify_feed_item(&item, &identity).is_ok());
16201 let mut tampered = item;
16202 tampered.entry.pack_sha256 = "c".repeat(64);
16203 assert!(verify_feed_item(&tampered, &identity).is_err());
16204 }
16205
16206 #[test]
16207 fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
16208 let rng = ring::rand::SystemRandom::new();
16209 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16210 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16211 let (spki, multikey) = public_identity_for(&pair);
16212 let identity = V2HeadIdentity {
16213 custody: "self".to_string(),
16214 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16215 public_key_spki: spki.clone(),
16216 previous: Vec::new(),
16217 rotations: Vec::new(),
16218 };
16219 let unsigned = json!({
16220 "actor_ref": "a".repeat(64),
16221 "asset_root": Value::Null,
16222 "brain": multikey,
16223 "changes_sha256": "b".repeat(64),
16224 "control_revision": "c".repeat(64),
16225 "materializer": "dbmd-projection-v1",
16226 "op": "changeset",
16227 "parent_asset_root": Value::Null,
16228 "parent_commit": Value::Null,
16229 "parent_root": Value::Null,
16230 "prev_entry_hash": Value::Null,
16231 "public_key": spki,
16232 "seq": 1,
16233 "signer_epoch": 1,
16234 "state_root": "d".repeat(64),
16235 "ts": "2026-08-19T12:00:00.000Z",
16236 "v": 2,
16237 "v1_bridge": {
16238 "feed_hash": "e".repeat(64),
16239 "head_seq": 7,
16240 "pack_sha256": "f".repeat(64),
16241 },
16242 });
16243 let sign_value = |value: Value| {
16244 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16245 let mut object = value.as_object().unwrap().clone();
16246 object.insert(
16247 "sig".to_string(),
16248 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16249 );
16250 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16251 };
16252 assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
16253
16254 let mut extra = unsigned.clone();
16255 extra
16256 .as_object_mut()
16257 .unwrap()
16258 .insert("future".to_string(), Value::Bool(true));
16259 assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
16260
16261 let mut missing = unsigned.clone();
16262 missing.as_object_mut().unwrap().remove("v1_bridge");
16263 assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
16264
16265 let mut invalid_bridge = unsigned;
16266 invalid_bridge.as_object_mut().unwrap().insert(
16267 "v1_bridge".to_string(),
16268 json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
16269 );
16270 assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
16271 }
16272
16273 #[test]
16274 fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
16275 let vector: Value = serde_json::from_str(include_str!(
16276 "../tests/vectors/linkmd-v2-commit-bridge.json"
16277 ))
16278 .unwrap();
16279 let identity_value = vector.get("identity").unwrap();
16280 let identity = V2HeadIdentity {
16281 custody: "self".to_string(),
16282 fingerprint: identity_value
16283 .get("fingerprint")
16284 .and_then(Value::as_str)
16285 .unwrap()
16286 .to_string(),
16287 public_key_spki: identity_value
16288 .get("public_key_spki")
16289 .and_then(Value::as_str)
16290 .unwrap()
16291 .to_string(),
16292 previous: Vec::new(),
16293 rotations: Vec::new(),
16294 };
16295 let private = URL_SAFE_NO_PAD
16296 .decode(
16297 identity_value
16298 .get("private_key_pkcs8")
16299 .and_then(Value::as_str)
16300 .unwrap(),
16301 )
16302 .unwrap();
16303 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
16304 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
16305 .unwrap();
16306 let base = vector.get("body").unwrap().as_object().unwrap();
16307
16308 for item in vector.get("valid").unwrap().as_array().unwrap() {
16309 let mut body = base.clone();
16310 body.insert(
16311 "v1_bridge".to_string(),
16312 item.get("v1_bridge").unwrap().clone(),
16313 );
16314 body.insert(
16315 "sig".to_string(),
16316 item.get("signature_base64url").unwrap().clone(),
16317 );
16318 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16319 assert!(verified_v2_commit_object(&signed, &identity).is_ok());
16320 assert_eq!(
16321 crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
16322 item.get("commit_hash").and_then(Value::as_str).unwrap()
16323 );
16324 assert_eq!(
16325 format!("{:x}", Sha256::digest(&signed)),
16326 item.get("feed_hash").and_then(Value::as_str).unwrap()
16327 );
16328 }
16329
16330 for item in vector.get("invalid").unwrap().as_array().unwrap() {
16331 let mut body = base.clone();
16332 if let Some(remove) = item.get("remove").and_then(Value::as_array) {
16333 for field in remove {
16334 body.remove(field.as_str().unwrap());
16335 }
16336 }
16337 if let Some(set) = item.get("set").and_then(Value::as_object) {
16338 for (field, value) in set {
16339 body.insert(field.clone(), value.clone());
16340 }
16341 }
16342 let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
16343 body.insert(
16344 "sig".to_string(),
16345 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16346 );
16347 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16348 assert!(
16349 verified_v2_commit_object(&signed, &identity).is_err(),
16350 "accepted invalid shared vector {}",
16351 item.get("reason").and_then(Value::as_str).unwrap()
16352 );
16353 }
16354 }
16355
16356 #[test]
16357 fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
16358 let vector: Value = serde_json::from_str(include_str!(
16359 "../tests/vectors/linkmd-v2-changeset-withheld.json"
16360 ))
16361 .unwrap();
16362 assert_eq!(
16363 vector.get("profile").and_then(Value::as_str),
16364 Some("link.md-v2-changeset-withheld")
16365 );
16366 let canonical =
16367 crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
16368 let expected = STANDARD
16369 .decode(
16370 vector
16371 .get("canonical_base64")
16372 .and_then(Value::as_str)
16373 .unwrap(),
16374 )
16375 .unwrap();
16376 assert_eq!(canonical, expected);
16377 assert_eq!(
16378 crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
16379 vector.get("domain_hash").and_then(Value::as_str).unwrap()
16380 );
16381 }
16382
16383 #[test]
16384 fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
16385 let remote = signed_remote_fixture();
16386 let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
16387 let legacy_item = legacy.entries.first().unwrap();
16388 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
16389 let body = json!({
16390 "actor_ref": "a".repeat(64),
16391 "asset_root": Value::Null,
16392 "brain": remote.key.multikey,
16393 "changes_sha256": "b".repeat(64),
16394 "control_revision": "c".repeat(64),
16395 "materializer": "dbmd-projection-v1",
16396 "op": "changeset",
16397 "parent_asset_root": Value::Null,
16398 "parent_commit": Value::Null,
16399 "parent_root": Value::Null,
16400 "prev_entry_hash": Value::Null,
16401 "public_key": remote.key.public_key_spki,
16402 "seq": 1,
16403 "signer_epoch": 1,
16404 "state_root": "d".repeat(64),
16405 "ts": "2026-08-19T12:00:00.000Z",
16406 "v": 2,
16407 "v1_bridge": {
16408 "feed_hash": legacy_item.hash,
16409 "head_seq": legacy_item.entry.seq,
16410 "pack_sha256": legacy_item.entry.pack_sha256,
16411 },
16412 });
16413 let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
16414 let mut signed = body.as_object().unwrap().clone();
16415 signed.insert(
16416 "sig".to_string(),
16417 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16418 );
16419 let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
16420 let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
16421 let feed_hash = content_sha256(&raw);
16422 let pointer = V2PointerBody {
16423 v: 2,
16424 brain: TEST_BRAIN_ID.to_string(),
16425 seq: 1,
16426 commit_hash: commit_hash.clone(),
16427 feed_hash: feed_hash.clone(),
16428 content_root: Some("d".repeat(64)),
16429 asset_root: None,
16430 materializer: "dbmd-projection-v1".to_string(),
16431 signer_epoch: 1,
16432 control_revision: "c".repeat(64),
16433 backup_preparation: "e".repeat(64),
16434 prior_pointer_hash: None,
16435 signed_at: "2026-08-19T12:00:00.000Z".to_string(),
16436 };
16437 let v2_page = json!({
16438 "v": 2,
16439 "head_seq": 1,
16440 "head_commit_hash": commit_hash,
16441 "head_feed_hash": feed_hash,
16442 "entries": [{
16443 "seq": 1,
16444 "commit_hash": pointer.commit_hash,
16445 "feed_hash": pointer.feed_hash,
16446 "bytes_base64": STANDARD.encode(&raw),
16447 }],
16448 "next_after": 1,
16449 "complete": true,
16450 })
16451 .to_string();
16452 let identity = V2HeadIdentity {
16453 custody: "self".to_string(),
16454 fingerprint: remote.identity.fingerprint.clone(),
16455 public_key_spki: remote.identity.public_key_spki.clone(),
16456 previous: Vec::new(),
16457 rotations: Vec::new(),
16458 };
16459 let checkpoint = TrustState {
16460 v: 2,
16461 origin: "unused".to_string(),
16462 requested: TEST_BRAIN_ID.to_string(),
16463 brain: TEST_BRAIN_ID.to_string(),
16464 home: None,
16465 anchor: remote.key.multikey.clone(),
16466 current: remote.key.multikey,
16467 head_seq: legacy_item.entry.seq,
16468 feed_hash: Some(legacy_item.hash.clone()),
16469 rotations: Vec::new(),
16470 hub_signer: None,
16471 protocol_profile: None,
16472 };
16473 let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
16474 let state = tempfile::tempdir().unwrap();
16475 let cfg = test_hub_config(hub, state.path().to_path_buf());
16476 verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
16477 server.join().unwrap();
16478
16479 let mut wrong = checkpoint;
16480 wrong.feed_hash = Some("0".repeat(64));
16481 let (hub, server) = scripted_json_hub(vec![(
16482 200,
16483 json!({
16484 "v": 2,
16485 "head_seq": 1,
16486 "head_commit_hash": pointer.commit_hash,
16487 "head_feed_hash": pointer.feed_hash,
16488 "entries": [{
16489 "seq": 1,
16490 "commit_hash": pointer.commit_hash,
16491 "feed_hash": pointer.feed_hash,
16492 "bytes_base64": STANDARD.encode(&raw),
16493 }],
16494 "next_after": 1,
16495 "complete": true,
16496 })
16497 .to_string(),
16498 )]);
16499 let state = tempfile::tempdir().unwrap();
16500 let cfg = test_hub_config(hub, state.path().to_path_buf());
16501 assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
16502 server.join().unwrap();
16503 }
16504
16505 #[test]
16506 fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
16507 let rng = ring::rand::SystemRandom::new();
16508 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16509 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
16510 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16511 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
16512 let (old_spki, old_multikey) = public_identity_for(&old);
16513 let (new_spki, new_multikey) = public_identity_for(&new);
16514 let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
16515 v: 1,
16516 op: "rotate",
16517 brain: &old_multikey,
16518 public_key: &old_spki,
16519 new_brain: &new_multikey,
16520 new_public_key: &new_spki,
16521 prior_head_seq: 1,
16522 prior_feed_hash: Some(&"9".repeat(64)),
16523 ts: "2026-08-19T12:01:00.000Z".to_string(),
16524 })
16525 .unwrap();
16526 let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
16527 let rotation = format!(
16528 "{},\"sig\":\"{}\"}}",
16529 &rotation_unsigned[..rotation_unsigned.len() - 1],
16530 rotation_sig
16531 );
16532 let identity = V2HeadIdentity {
16533 custody: "self".to_string(),
16534 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
16535 public_key_spki: new_spki.clone(),
16536 previous: vec![V2PreviousIdentity {
16537 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
16538 public_key_spki: old_spki.clone(),
16539 }],
16540 rotations: vec![rotation],
16541 };
16542 let commit = |seq: u64,
16543 epoch: u64,
16544 multikey: &str,
16545 spki: &str,
16546 pair: &ring::signature::Ed25519KeyPair| {
16547 let value = json!({
16548 "actor_ref": "a".repeat(64),
16549 "asset_root": Value::Null,
16550 "brain": multikey,
16551 "changes_sha256": "b".repeat(64),
16552 "control_revision": "c".repeat(64),
16553 "materializer": "dbmd-projection-v1",
16554 "op": "changeset",
16555 "parent_asset_root": Value::Null,
16556 "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
16557 "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
16558 "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
16559 "public_key": spki,
16560 "seq": seq,
16561 "signer_epoch": epoch,
16562 "state_root": "1".repeat(64),
16563 "ts": "2026-08-19T12:00:00.000Z",
16564 "v": 2,
16565 "v1_bridge": Value::Null,
16566 });
16567 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16568 let mut object = value.as_object().unwrap().clone();
16569 object.insert(
16570 "sig".to_string(),
16571 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16572 );
16573 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16574 };
16575
16576 assert!(verified_v2_commit_object(
16577 &commit(1, 1, &old_multikey, &old_spki, &old),
16578 &identity,
16579 )
16580 .is_ok());
16581 assert!(verified_v2_commit_object(
16582 &commit(2, 2, &new_multikey, &new_spki, &new),
16583 &identity,
16584 )
16585 .is_ok());
16586 assert!(verified_v2_commit_object(
16587 &commit(2, 1, &old_multikey, &old_spki, &old),
16588 &identity,
16589 )
16590 .is_err());
16591 assert!(verified_v2_commit_object(
16592 &commit(1, 2, &new_multikey, &new_spki, &new),
16593 &identity,
16594 )
16595 .is_err());
16596 }
16597
16598 #[test]
16599 fn a_self_custody_entry_verifies_like_any_hub_entry() {
16600 let rng = ring::rand::SystemRandom::new();
16601 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16602 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16603 let (spki, multikey) = public_identity_for(&pair);
16604 let key = AgentSigningKey {
16605 pkcs8: pkcs8.as_ref().to_vec(),
16606 multikey: multikey.clone(),
16607 public_key_spki: spki.clone(),
16608 };
16609 let files = vec![WireFeedFile {
16610 path: "DB.md".to_string(),
16611 sha256: "a".repeat(64),
16612 bytes: 3,
16613 }];
16614 let raw = self_custody_entry(
16615 &key,
16616 1,
16617 "2026-07-23T12:00:00.000Z".to_string(),
16618 &"c".repeat(64),
16619 &files,
16620 None,
16621 )
16622 .unwrap();
16623 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
16627 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
16628 let item = FeedItem { hash, entry };
16629 let identity = FeedIdentity {
16630 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16631 public_key_spki: spki,
16632 previous: Vec::new(),
16633 rotations: Vec::new(),
16634 };
16635 assert!(verify_feed_item(&item, &identity).is_ok());
16636 }
16637
16638 #[test]
16639 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
16640 let rng = ring::rand::SystemRandom::new();
16641 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16642 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
16643 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16644 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
16645 let (old_spki, old_multikey) = public_identity_for(&old);
16646 let (new_spki, new_multikey) = public_identity_for(&new);
16647 let unsigned = serde_json::to_string(&UnsignedRotation {
16648 v: 1,
16649 op: "rotate",
16650 brain: &old_multikey,
16651 public_key: &old_spki,
16652 new_brain: &new_multikey,
16653 new_public_key: &new_spki,
16654 prior_head_seq: 1,
16655 prior_feed_hash: Some(&"a".repeat(64)),
16656 ts: "2026-07-30T12:00:00.000Z".to_string(),
16657 })
16658 .unwrap();
16659 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
16660 let rotation = format!(
16661 "{},\"sig\":\"{}\"}}",
16662 &unsigned[..unsigned.len() - 1],
16663 signature
16664 );
16665 let identity = FeedIdentity {
16666 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
16667 public_key_spki: new_spki,
16668 previous: vec![PreviousIdentity {
16669 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
16670 public_key_spki: old_spki,
16671 }],
16672 rotations: vec![rotation],
16673 };
16674 let pin = TrustState {
16675 v: 2,
16676 origin: "https://hub.example".to_string(),
16677 requested: "brain".to_string(),
16678 brain: "brain".to_string(),
16679 home: None,
16680 anchor: old_multikey.clone(),
16681 current: old_multikey.clone(),
16682 head_seq: 1,
16683 feed_hash: Some("a".repeat(64)),
16684 rotations: Vec::new(),
16685 hub_signer: None,
16686 protocol_profile: None,
16687 };
16688 assert_eq!(
16689 verify_identity_chain(&identity, Some(&pin)).unwrap(),
16690 old_multikey
16691 );
16692 let mut accepted = pin.clone();
16693 accepted.current = new_multikey.clone();
16694 accepted.rotations = identity.rotations.clone();
16695 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
16696 v: 1,
16697 op: "rotate",
16698 brain: &old_multikey,
16699 public_key: &identity.previous[0].public_key_spki,
16700 new_brain: &new_multikey,
16701 new_public_key: &identity.public_key_spki,
16702 prior_head_seq: 1,
16703 prior_feed_hash: Some(&"a".repeat(64)),
16704 ts: "2026-07-30T12:00:01.000Z".to_string(),
16705 })
16706 .unwrap();
16707 let alternate_signature =
16708 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
16709 let mut rewritten = identity.clone();
16710 rewritten.rotations[0] = format!(
16711 "{},\"sig\":\"{}\"}}",
16712 &alternate_unsigned[..alternate_unsigned.len() - 1],
16713 alternate_signature
16714 );
16715 assert!(
16716 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
16717 "an alternate valid statement must not rewrite accepted history"
16718 );
16719
16720 let mut stale_entry = FeedEntry {
16721 v: 1,
16722 seq: 2,
16723 ts: "2026-07-30T12:01:00.000Z".to_string(),
16724 brain: pin.current.clone(),
16725 public_key: identity.previous[0].public_key_spki.clone(),
16726 kind: "push".to_string(),
16727 op: "snapshot".to_string(),
16728 pack_sha256: "b".repeat(64),
16729 files: Vec::new(),
16730 removed: Vec::new(),
16731 prev_entry_hash: pin.feed_hash.clone(),
16732 sig: String::new(),
16733 };
16734 let stale_unsigned = UnsignedFeedEntry {
16735 v: stale_entry.v,
16736 seq: stale_entry.seq,
16737 ts: &stale_entry.ts,
16738 brain: &stale_entry.brain,
16739 public_key: &stale_entry.public_key,
16740 kind: &stale_entry.kind,
16741 op: &stale_entry.op,
16742 pack_sha256: &stale_entry.pack_sha256,
16743 files: &stale_entry.files,
16744 removed: &stale_entry.removed,
16745 prev_entry_hash: &stale_entry.prev_entry_hash,
16746 };
16747 stale_entry.sig = URL_SAFE_NO_PAD.encode(
16748 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
16749 .as_ref(),
16750 );
16751 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
16752 stale_exact.push(b'\n');
16753 let stale_item = FeedItem {
16754 hash: content_sha256(&stale_exact),
16755 entry: stale_entry,
16756 };
16757 assert!(
16758 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
16759 .is_err(),
16760 "a key retired before the checkpoint must never append after it"
16761 );
16762 assert!(
16763 verify_feed_item(&stale_item, &identity).is_err(),
16764 "an old key must never append after its signed rotation boundary"
16765 );
16766
16767 let mut missing = identity.clone();
16768 missing.rotations.clear();
16769 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
16770
16771 let mut tampered = identity;
16772 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
16773 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
16774 }
16775
16776 #[cfg(unix)]
16777 #[test]
16778 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
16779 use std::os::unix::fs::symlink;
16780
16781 let dir = tempfile::tempdir().unwrap();
16782 let target = dir.path().join("valuable.txt");
16783 let planted = dir.path().join("agent.key");
16784 std::fs::write(&target, "do not overwrite").unwrap();
16785 symlink(&target, &planted).unwrap();
16786
16787 assert!(matches!(
16788 generate_agent_key(&planted),
16789 Err(LinkError::BadAgentKey { .. })
16790 ));
16791 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
16792 }
16793
16794 #[cfg(unix)]
16795 #[test]
16796 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
16797 use std::os::unix::fs::symlink;
16798
16799 let root = tempfile::tempdir().unwrap();
16800 let outside = tempfile::tempdir().unwrap();
16801 symlink(outside.path(), root.path().join("redirect")).unwrap();
16802
16803 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
16804 assert!(!outside.path().join("agent.key").exists());
16805 }
16806
16807 #[test]
16810 fn address_bare_brain_with_and_without_sigil() {
16811 for raw in ["@acme-ops", "acme-ops"] {
16812 let a = Address::parse(raw).expect(raw);
16813 assert_eq!(a.brain, "acme-ops");
16814 assert_eq!(a.target, None);
16815 }
16816 }
16817
16818 #[test]
16819 fn address_ulid_target_parses_as_id() {
16820 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
16821 assert_eq!(a.brain, "acme");
16822 assert_eq!(
16823 a.target,
16824 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
16825 );
16826 }
16827
16828 #[test]
16829 fn address_md_path_target_parses_as_path() {
16830 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
16831 assert_eq!(
16832 a.target,
16833 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
16834 );
16835 }
16836
16837 #[test]
16838 fn address_rejects_malformed_forms() {
16839 for raw in [
16840 "",
16841 "@",
16842 "@/x",
16843 "@acme/",
16844 "@acme/../etc/passwd",
16845 "@acme/records/.hidden.md",
16846 "@ACME", "@acme/notes/x.txt", "@a b", ] {
16850 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
16851 }
16852 }
16853
16854 #[test]
16857 fn safe_paths_accept_store_shapes_and_reject_escapes() {
16858 for ok in [
16859 "DB.md",
16860 "assets.jsonl",
16861 "records/clients/lumio.md",
16862 "sources/emails/2026/07/x.md",
16863 ] {
16864 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
16865 }
16866 for bad in [
16867 "",
16868 "/etc/passwd",
16869 "../up.md",
16870 "records/../../up.md",
16871 "records//x.md",
16872 ".dbmd/config",
16873 "records/.hidden/x.md",
16874 "records/a b.md",
16875 "records\\win.md",
16876 ] {
16877 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
16878 }
16879 }
16880
16881 #[cfg(unix)]
16882 #[test]
16883 fn opened_destination_capability_survives_an_ancestor_path_swap() {
16884 use std::os::unix::fs::symlink;
16885
16886 let work = tempfile::tempdir().unwrap();
16887 let outside = tempfile::tempdir().unwrap();
16888 let original = work.path().join("destination");
16889 let moved = work.path().join("destination-moved");
16890 let directory = open_or_create_dir_nofollow(&original).unwrap();
16891
16892 std::fs::rename(&original, &moved).unwrap();
16893 symlink(outside.path(), &original).unwrap();
16894 write_pull_entries_beneath_dir(
16895 &directory,
16896 &[("records/note.md".to_string(), b"held inode".to_vec())],
16897 )
16898 .unwrap();
16899
16900 assert_eq!(
16901 std::fs::read(moved.join("records/note.md")).unwrap(),
16902 b"held inode"
16903 );
16904 assert!(!outside.path().join("records/note.md").exists());
16905 }
16906
16907 #[test]
16911 fn hub_config_flag_beats_file_and_requires_some_source() {
16912 let dir = tempfile::tempdir().unwrap();
16913 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
16914 std::fs::write(
16915 dir.path().join(CONFIG_REL_PATH),
16916 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
16917 )
16918 .unwrap();
16919
16920 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
16921 assert_eq!(from_flag.hub, "https://flag.example.com");
16922
16923 let from_file = hub_config(None, dir.path()).unwrap();
16924 assert_eq!(from_file.hub, "https://file.example.com");
16925
16926 let none = hub_config(None, tempfile::tempdir().unwrap().path());
16927 assert!(matches!(none, Err(LinkError::NoHub)));
16928 }
16929
16930 #[test]
16931 fn https_guard_allows_loopback_only_for_plain_http() {
16932 assert!(assert_safe_hub("https://hub.example.com").is_ok());
16933 assert!(assert_safe_hub("http://localhost:3000").is_ok());
16934 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
16935 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
16936 assert!(matches!(
16937 assert_safe_hub("http://hub.example.com"),
16938 Err(LinkError::UnsafeHub { .. })
16939 ));
16940 assert!(matches!(
16941 assert_safe_hub("hub.example.com"),
16942 Err(LinkError::UnsafeHub { .. })
16943 ));
16944 assert!(matches!(
16945 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
16946 Err(LinkError::UnsafeHub { .. })
16947 ));
16948 assert!(matches!(
16949 assert_safe_hub("https://hub.example.com@attacker.example"),
16950 Err(LinkError::UnsafeHub { .. })
16951 ));
16952 assert!(matches!(
16953 assert_safe_hub("https://hub.example.com/base"),
16954 Err(LinkError::UnsafeHub { .. })
16955 ));
16956 }
16957
16958 #[test]
16959 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
16960 for blocked in [
16961 "127.0.0.1",
16962 "10.0.0.1",
16963 "100.64.0.1",
16964 "169.254.169.254",
16965 "172.16.0.1",
16966 "192.168.0.1",
16967 "192.88.99.1",
16968 "198.18.0.1",
16969 "203.0.113.1",
16970 "::1",
16971 "fe80::1",
16972 "fd00::1",
16973 "2001:db8::1",
16974 "2001:1::1",
16975 "2002:7f00:1::",
16976 "3fff::1",
16977 ] {
16978 assert!(
16979 !is_public_registry_ip(blocked.parse().unwrap()),
16980 "must block {blocked}"
16981 );
16982 }
16983 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
16984 assert!(is_public_registry_ip(
16985 "2606:4700:4700::1111".parse().unwrap()
16986 ));
16987 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
16988 }
16989
16990 #[test]
16991 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
16992 use ureq::Resolver as _;
16993
16994 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
16995 let resolver = PinnedRegistryResolver {
16996 netloc: "home.example:443".to_string(),
16997 addresses: vec![pinned],
16998 };
16999 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
17000 assert!(resolver.resolve("127.0.0.1:443").is_err());
17001 assert_eq!(
17002 resolver.resolve("home.example:443").unwrap(),
17003 vec![pinned],
17004 "subsequent connects reuse the validated answer instead of DNS"
17005 );
17006 }
17007
17008 #[test]
17009 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
17010 let cfg = HubConfig {
17011 hub: "https://hub.example".to_string(),
17012 key: None,
17013 agent_key: None,
17014 brain_key: None,
17015 state_dir: tempfile::tempdir().unwrap().keep(),
17016 store_selected: false,
17017 };
17018 assert!(
17019 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
17020 "a production hub must not turn its presigned URL into an SSRF primitive"
17021 );
17022
17023 let store_selected = HubConfig {
17024 hub: "https://127.0.0.1".to_string(),
17025 store_selected: true,
17026 ..cfg
17027 };
17028 assert!(
17029 hub_agent(&store_selected).is_err(),
17030 "bytes in a cloned store must not select a private-network hub"
17031 );
17032 }
17033
17034 #[test]
17035 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
17036 assert_eq!(
17037 one_past_bounded_limit(MAX_PACK_BYTES),
17038 Some(MAX_PACK_BYTES + 1),
17039 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
17040 );
17041 assert_eq!(
17042 presigned_download_read_limit(),
17043 MAX_PACK_BYTES + 1,
17044 "the presigned reader is capped by the client constant, not a hub response"
17045 );
17046 assert_eq!(
17047 one_past_bounded_limit(u64::MAX),
17048 None,
17049 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
17050 );
17051 }
17052
17053 #[test]
17054 fn https_guard_matches_the_scheme_case_insensitively() {
17055 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
17058 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
17059 assert!(matches!(
17061 assert_safe_hub("HTTP://hub.example.com"),
17062 Err(LinkError::UnsafeHub { .. })
17063 ));
17064 }
17065
17066 #[test]
17067 fn clean_key_refuses_paste_artifacts_without_echoing() {
17068 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
17069 for bad in ["vc account", "vc\naccount", "ключ", ""] {
17070 let err = clean_key(bad).unwrap_err();
17071 assert!(matches!(err, LinkError::BadKey));
17072 assert!(
17073 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
17074 "error must not echo the key"
17075 );
17076 }
17077 }
17078
17079 fn dead_hub() -> HubConfig {
17085 HubConfig {
17086 hub: "http://127.0.0.1:9".to_string(),
17087 key: Some("k".to_string()),
17088 agent_key: None,
17089 brain_key: None,
17090 state_dir: PathBuf::from("."),
17091 store_selected: false,
17092 }
17093 }
17094
17095 #[test]
17096 fn request_retries_a_connection_failure_before_sending() {
17097 use std::io::{Read as _, Write as _};
17098 use std::net::TcpListener;
17099 use std::thread;
17100 use std::time::Duration;
17101
17102 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
17103 let address = probe.local_addr().unwrap();
17104 drop(probe);
17105 let server = thread::spawn(move || {
17106 thread::sleep(Duration::from_millis(40));
17107 let listener = TcpListener::bind(address).unwrap();
17108 let (mut stream, _) = listener.accept().unwrap();
17109 let mut request_bytes = [0_u8; 1024];
17110 let _ = stream.read(&mut request_bytes).unwrap();
17111 stream
17112 .write_all(
17113 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17114 )
17115 .unwrap();
17116 });
17117 let cfg = HubConfig {
17118 hub: format!("http://{address}"),
17119 key: None,
17120 agent_key: None,
17121 brain_key: None,
17122 state_dir: tempfile::tempdir().unwrap().keep(),
17123 store_selected: false,
17124 };
17125
17126 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
17127 assert_eq!(response.status, 200);
17128 assert_eq!(response.body, Some(json!({ "ok": true })));
17129 server.join().unwrap();
17130 }
17131
17132 #[test]
17133 fn a_commit_goes_back_for_a_receipt_it_lost() {
17134 use std::io::{Read as _, Write as _};
17135 use std::net::TcpListener;
17136 use std::thread;
17137
17138 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17144 let address = listener.local_addr().unwrap();
17145 let server = thread::spawn(move || {
17146 let (mut first, _) = listener.accept().unwrap();
17148 let mut bytes = [0_u8; 4096];
17149 let _ = first.read(&mut bytes).unwrap();
17150 first
17151 .write_all(
17152 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
17153 )
17154 .unwrap();
17155 drop(first);
17156 let (mut second, _) = listener.accept().unwrap();
17158 let _ = second.read(&mut bytes).unwrap();
17159 second
17160 .write_all(
17161 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\"}",
17162 )
17163 .unwrap();
17164 });
17165 let cfg = HubConfig {
17166 hub: format!("http://{address}"),
17167 key: Some("k".to_string()),
17168 agent_key: None,
17169 brain_key: None,
17170 state_dir: tempfile::tempdir().unwrap().keep(),
17171 store_selected: false,
17172 };
17173
17174 let response = request_patient(
17175 &cfg,
17176 "POST",
17177 "/api/hub/brains/b/v2/commits",
17178 Some(&json!({ "mutation_id": "dbmd-1" })),
17179 Auth::Required,
17180 )
17181 .expect("the receipt is collected on the second ask");
17182 assert_eq!(response.status, 200);
17183 assert_eq!(
17184 response
17185 .body
17186 .as_ref()
17187 .and_then(|value| value.get("outcome"))
17188 .and_then(Value::as_str),
17189 Some("converged"),
17190 "an already-applied mutation answers with its receipt"
17191 );
17192 server.join().unwrap();
17193 }
17194
17195 #[test]
17196 fn a_body_that_dies_mid_stream_is_a_transport_failure() {
17197 use std::io::{Read as _, Write as _};
17198 use std::net::TcpListener;
17199 use std::thread;
17200
17201 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17207 let address = listener.local_addr().unwrap();
17208 let server = thread::spawn(move || {
17209 let (mut stream, _) = listener.accept().unwrap();
17210 let mut request_bytes = [0_u8; 1024];
17211 let _ = stream.read(&mut request_bytes).unwrap();
17212 stream
17214 .write_all(
17215 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17216 )
17217 .unwrap();
17218 });
17219 let cfg = HubConfig {
17220 hub: format!("http://{address}"),
17221 key: None,
17222 agent_key: None,
17223 brain_key: None,
17224 state_dir: tempfile::tempdir().unwrap().keep(),
17225 store_selected: false,
17226 };
17227
17228 let error = request(&cfg, "GET", "/truncated", None, Auth::None)
17229 .expect_err("a truncated body must not read as success");
17230 match error {
17231 LinkError::Transport { hub, .. } => {
17232 assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
17233 }
17234 other => panic!("expected a transport failure, got {other:?}"),
17235 }
17236 server.join().unwrap();
17237 }
17238
17239 #[test]
17240 fn endpoint_cap_refuses_a_body_before_json_parsing() {
17241 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
17242 let cfg = HubConfig {
17243 hub,
17244 key: None,
17245 agent_key: None,
17246 brain_key: None,
17247 state_dir: tempfile::tempdir().unwrap().keep(),
17248 store_selected: false,
17249 };
17250
17251 assert!(matches!(
17252 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
17253 Err(LinkError::ResponseTooLarge { .. })
17254 ));
17255 server.join().unwrap();
17256 }
17257
17258 #[test]
17259 fn overall_deadline_stops_a_dribbled_response_body() {
17260 use std::io::{Read as _, Write as _};
17261 use std::net::TcpListener;
17262 use std::time::{Duration, Instant};
17263
17264 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17265 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
17266 let server = std::thread::spawn(move || {
17267 let (mut stream, _) = listener.accept().unwrap();
17268 let mut request = [0_u8; 1024];
17269 let _ = stream.read(&mut request);
17270 stream
17271 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
17272 .unwrap();
17273 for byte in [b'x'; 32] {
17274 if stream.write_all(&[byte]).is_err() {
17275 break;
17276 }
17277 std::thread::sleep(Duration::from_millis(40));
17278 }
17279 });
17280 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17281 let started = Instant::now();
17282 let response = http.get(&url).call().unwrap();
17283 let mut body = Vec::new();
17284 let error = response
17285 .into_reader()
17286 .read_to_end(&mut body)
17287 .expect_err("per-read progress must not reset the overall deadline");
17288 assert!(
17289 started.elapsed() < Duration::from_millis(700),
17290 "dribbled body exceeded the wall-clock budget: {error}"
17291 );
17292 server.join().unwrap();
17293 }
17294
17295 #[test]
17296 fn overall_deadline_stops_a_stalled_upload() {
17297 use std::net::TcpListener;
17298 use std::time::{Duration, Instant};
17299
17300 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17301 let url = format!("http://{}/upload", listener.local_addr().unwrap());
17302 let server = std::thread::spawn(move || {
17303 let (_stream, _) = listener.accept().unwrap();
17304 std::thread::sleep(Duration::from_millis(600));
17307 });
17308 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17309 let body = vec![0x5a; 32 * 1024 * 1024];
17310 let started = Instant::now();
17311 let error = http
17312 .put(&url)
17313 .send_bytes(&body)
17314 .expect_err("stalled request-body writes must time out");
17315 assert!(
17316 started.elapsed() < Duration::from_millis(700),
17317 "stalled upload exceeded the wall-clock budget: {error}"
17318 );
17319 server.join().unwrap();
17320 }
17321
17322 #[test]
17323 fn verb_entry_gates_accept_the_hub_ref_shapes() {
17324 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
17325 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
17326 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
17327 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
17328 }
17329 }
17330
17331 #[test]
17332 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
17333 let cfg = dead_hub();
17334 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
17335 assert!(
17336 matches!(
17337 sync_pull(&cfg, bad, None),
17338 Err(LinkError::BadAddress { .. })
17339 ),
17340 "sync_pull must refuse {bad:?}"
17341 );
17342 assert!(
17343 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
17344 "sync_push must refuse {bad:?}"
17345 );
17346 assert!(
17347 matches!(
17348 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
17349 Err(LinkError::BadAddress { .. })
17350 ),
17351 "grant_issue must refuse {bad:?}"
17352 );
17353 assert!(
17354 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
17355 "grant_list must refuse {bad:?}"
17356 );
17357 assert!(
17358 matches!(
17359 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
17360 Err(LinkError::BadAddress { .. })
17361 ),
17362 "grant_revoke must refuse brain {bad:?}"
17363 );
17364 assert!(
17365 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
17366 "head must refuse {bad:?}"
17367 );
17368 }
17369 }
17370
17371 #[test]
17372 fn grant_revoke_refuses_url_reshaping_grant_ids() {
17373 let cfg = dead_hub();
17374 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
17375 assert!(
17376 matches!(
17377 grant_revoke(&cfg, "acme", bad),
17378 Err(LinkError::BadGrantId { .. })
17379 ),
17380 "grant_revoke must refuse grant id {bad:?}"
17381 );
17382 }
17383 }
17384
17385 #[test]
17386 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
17387 let cfg = dead_hub();
17388 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
17389 assert!(
17390 matches!(
17391 propose(&cfg, bad, "intake", "hi"),
17392 Err(LinkError::BadAddress { .. })
17393 ),
17394 "propose must refuse handle {bad:?}"
17395 );
17396 }
17397 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
17398 assert!(matches!(
17399 propose(&cfg, "acme-site", "intake", &oversize),
17400 Err(LinkError::ProposeTooLarge { .. })
17401 ));
17402 assert!(matches!(
17405 propose(&cfg, "acme-site", "intake", "hi"),
17406 Err(LinkError::Transport { .. })
17407 ));
17408 }
17409
17410 #[test]
17411 fn resolve_refuses_a_hand_built_unsafe_address() {
17412 let cfg = dead_hub();
17413 for brain in ["../up", "a/b", "a?x", "a#f"] {
17414 let addr = Address {
17415 brain: brain.to_string(),
17416 target: None,
17417 };
17418 assert!(
17419 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
17420 "resolve must refuse brain {brain:?}"
17421 );
17422 }
17423 for target in [
17424 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
17425 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
17427 AddressTarget::Path("records/x.md#frag".to_string()),
17428 ] {
17429 let addr = Address {
17430 brain: "acme".to_string(),
17431 target: Some(target.clone()),
17432 };
17433 assert!(
17434 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
17435 "resolve must refuse target {target:?}"
17436 );
17437 }
17438 }
17439
17440 #[test]
17441 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
17442 let mut local = std::collections::BTreeMap::new();
17443 local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
17444 local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
17445 let mut remote = std::collections::BTreeMap::new();
17446 remote.insert(
17447 "records/a.md".to_string(),
17448 V2BaselineFile {
17449 sha256: "c".repeat(64),
17450 bytes: 1,
17451 proof: None,
17452 },
17453 );
17454 remote.insert(
17455 "records/b.md".to_string(),
17456 V2BaselineFile {
17457 sha256: "b".repeat(64),
17458 bytes: 1,
17459 proof: None,
17460 },
17461 );
17462 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
17463 }
17464
17465 #[test]
17466 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
17467 let local = std::collections::BTreeMap::new();
17468 let mut remote = std::collections::BTreeMap::new();
17469 remote.insert(
17470 "private/local.md".to_string(),
17471 V2BaselineFile {
17472 sha256: "d".repeat(64),
17473 bytes: 1,
17474 proof: None,
17475 },
17476 );
17477 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
17478 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
17479 }
17480
17481 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
17482 V2VerifiedHead {
17483 requested: TEST_BRAIN_ID.to_string(),
17484 brain_id: TEST_BRAIN_ID.to_string(),
17485 view_kind: "scoped".to_string(),
17486 view_revision: revision.to_string(),
17487 control_revision: revision.to_string(),
17488 identity: V2HeadIdentity {
17489 custody: "hub".to_string(),
17490 fingerprint: "test".to_string(),
17491 public_key_spki: "test".to_string(),
17492 previous: Vec::new(),
17493 rotations: Vec::new(),
17494 },
17495 pointer: None,
17496 trust: TrustState {
17497 v: 2,
17498 origin: "https://hub.example".to_string(),
17499 requested: TEST_BRAIN_ID.to_string(),
17500 brain: TEST_BRAIN_ID.to_string(),
17501 home: None,
17502 anchor: "ed25519:test".to_string(),
17503 current: "ed25519:test".to_string(),
17504 head_seq: 0,
17505 feed_hash: None,
17506 rotations: Vec::new(),
17507 hub_signer: None,
17508 protocol_profile: Some("link-v2".to_string()),
17509 },
17510 alias: None,
17511 }
17512 }
17513
17514 #[test]
17515 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
17516 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
17517 assert!(accepted_as_v2(&trust));
17518
17519 trust.protocol_profile = None;
17520 trust.hub_signer = Some("ed25519:hub".to_string());
17521 assert!(accepted_as_v2(&trust));
17522
17523 trust.hub_signer = None;
17524 assert!(!accepted_as_v2(&trust));
17525 }
17526
17527 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
17528 V2SyncBaseline {
17529 v: 2,
17530 origin: "https://hub.example".to_string(),
17531 brain: TEST_BRAIN_ID.to_string(),
17532 checkout_id: Some("c".repeat(64)),
17533 head_seq: Some(0),
17534 commit_hash: None,
17535 content_root: None,
17536 asset_root: None,
17537 assets: std::collections::BTreeMap::new(),
17538 view_kind: Some("scoped".to_string()),
17539 view_revision: Some(revision.to_string()),
17540 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
17541 files: std::collections::BTreeMap::new(),
17542 local_policy_digest: None,
17543 local_eligibility: std::collections::BTreeMap::new(),
17544 remote_copy_remains: std::collections::BTreeMap::new(),
17545 }
17546 }
17547
17548 #[test]
17549 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
17550 let directory = tempfile::tempdir().unwrap();
17551 std::fs::write(
17552 directory.path().join("DB.md"),
17553 scoped_projection_bytes(TEST_BRAIN_ID),
17554 )
17555 .unwrap();
17556 let store = Store::open_strict(directory.path()).unwrap();
17557 let head = scoped_test_head(&"a".repeat(64));
17558 let baseline = scoped_test_baseline(&"a".repeat(64));
17559 let mut view = v2_local_files(&store).unwrap();
17560 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
17561 assert!(!view.riding.contains_key("DB.md"));
17562 assert!(!view.eligibility.contains_key("DB.md"));
17563 }
17564
17565 #[test]
17566 fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
17567 let directory = tempfile::tempdir().unwrap();
17568 std::fs::write(
17569 directory.path().join("DB.md"),
17570 scoped_projection_bytes(TEST_BRAIN_ID),
17571 )
17572 .unwrap();
17573 let store = Store::open_strict(directory.path()).unwrap();
17574 let head = scoped_test_head(&"a".repeat(64));
17575 let baseline = scoped_test_baseline(&"a".repeat(64));
17576
17577 let mut carried = v2_local_files(&store).unwrap();
17578 remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
17579 let handed_off =
17580 local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
17581 assert!(!handed_off.riding.contains_key("DB.md"));
17582
17583 let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
17584 assert!(!freshly_scanned.riding.contains_key("DB.md"));
17585
17586 std::fs::write(
17587 directory.path().join("DB.md"),
17588 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
17589 )
17590 .unwrap();
17591 let tampered = Store::open_strict(directory.path()).unwrap();
17592 assert!(matches!(
17593 local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
17594 Err(LinkError::ScopedProjectionModified)
17595 ));
17596 }
17597
17598 #[test]
17599 fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
17600 let directory = tempfile::tempdir().unwrap();
17601 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
17602 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
17603 std::fs::write(
17604 directory.path().join("DB.md"),
17605 b"---\nname: Kept home test\n---\n",
17606 )
17607 .unwrap();
17608 std::fs::write(
17609 directory.path().join("records/notes/a.md"),
17610 b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
17611 )
17612 .unwrap();
17613 std::fs::write(
17614 directory.path().join("sources/private/secret.md"),
17615 b"---\ntype: note\n---\nlocal only\n",
17616 )
17617 .unwrap();
17618 std::fs::write(
17619 directory.path().join("sources/private/unlinked.md"),
17620 b"---\ntype: note\n---\nnot disclosed\n",
17621 )
17622 .unwrap();
17623 std::fs::write(
17624 directory.path().join(".sevralocal"),
17625 b"sources/private/**\n",
17626 )
17627 .unwrap();
17628
17629 let store = Store::open_strict(directory.path()).unwrap();
17630 let view = v2_local_files(&store).unwrap();
17631 assert!(!view.riding.contains_key("sources/private/secret.md"));
17632 assert_eq!(
17633 view.withheld_links,
17634 vec![V2WithheldLink {
17635 source: "records/notes/a.md".to_string(),
17636 target: "sources/private/secret.md".to_string(),
17637 }]
17638 );
17639 }
17640
17641 #[test]
17642 fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
17643 let directory = tempfile::tempdir().unwrap();
17648 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
17649 std::fs::write(
17650 directory.path().join("DB.md"),
17651 b"---\nname: Restored export\n---\n",
17652 )
17653 .unwrap();
17654 std::fs::write(
17655 directory.path().join("records/notes/a.md"),
17656 b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
17657 )
17658 .unwrap();
17659 std::fs::write(
17660 directory.path().join(".sevralocal"),
17661 b"sources/private/**\n",
17662 )
17663 .unwrap();
17664
17665 let store = Store::open_strict(directory.path()).unwrap();
17666 let view = v2_local_files(&store).unwrap();
17667 assert_eq!(
17668 view.withheld_links,
17669 vec![V2WithheldLink {
17670 source: "records/notes/a.md".to_string(),
17671 target: "sources/private/absent.md".to_string(),
17672 }]
17673 );
17674 std::fs::write(
17676 directory.path().join("records/notes/b.md"),
17677 b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
17678 )
17679 .unwrap();
17680 let store = Store::open_strict(directory.path()).unwrap();
17681 let view = v2_local_files(&store).unwrap();
17682 assert!(
17683 !view
17684 .withheld_links
17685 .iter()
17686 .any(|link| link.target == "records/notes/nowhere.md"),
17687 "an unclaimed dangling target must not be declared withheld"
17688 );
17689 }
17690
17691 #[test]
17692 fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
17693 let directory = tempfile::tempdir().unwrap();
17694 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
17695 std::fs::write(
17696 directory.path().join("DB.md"),
17697 b"---\nname: Withdrawal test\n---\n",
17698 )
17699 .unwrap();
17700 let source = b"---\ntype: note\n---\nlocal evidence\n";
17701 std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
17702 std::fs::write(
17703 directory.path().join(".sevralocal"),
17704 b"sources/private/**\n",
17705 )
17706 .unwrap();
17707 let store = Store::open_strict(directory.path()).unwrap();
17708 let view = v2_local_files(&store).unwrap();
17709 let mut remote = std::collections::BTreeMap::new();
17710 remote.insert(
17711 "sources/private/evidence.md".to_string(),
17712 V2BaselineFile {
17713 sha256: content_sha256(source),
17714 bytes: source.len() as u64,
17715 proof: None,
17716 },
17717 );
17718 assert_eq!(
17719 v2_content_withdrawal_operation(
17720 &store,
17721 &view,
17722 &remote,
17723 "sources/private/evidence.md",
17724 "approved retention change",
17725 )
17726 .unwrap(),
17727 json!({
17728 "op": "withdraw_from_hosting",
17729 "path": "sources/private/evidence.md",
17730 "expected": { "kind": "blob", "hash": content_sha256(source) },
17731 "reason": "approved retention change",
17732 })
17733 );
17734
17735 std::fs::write(
17736 directory.path().join("sources/private/evidence.md"),
17737 b"changed after review",
17738 )
17739 .unwrap();
17740 assert!(matches!(
17741 v2_content_withdrawal_operation(
17742 &store,
17743 &view,
17744 &remote,
17745 "sources/private/evidence.md",
17746 "approved retention change",
17747 ),
17748 Err(LinkError::InvalidPack { .. })
17749 ));
17750 }
17751
17752 #[test]
17753 fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
17754 let directory = tempfile::tempdir().unwrap();
17755 std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
17756 std::fs::write(
17757 directory.path().join("DB.md"),
17758 b"---\nname: Asset withdrawal test\n---\n",
17759 )
17760 .unwrap();
17761 let bytes = b"private binary";
17762 std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
17763 std::fs::write(
17764 directory.path().join(".sevralocal"),
17765 b"sources/files/private.pdf\n",
17766 )
17767 .unwrap();
17768 let store = Store::open_strict(directory.path()).unwrap();
17769 let view = v2_local_files(&store).unwrap();
17770 let local = crate::AssetRecord {
17771 path: "sources/files/private.pdf".to_string(),
17772 sha256: content_sha256(bytes),
17773 bytes: bytes.len() as u64,
17774 media_type: "application/pdf".to_string(),
17775 wrappers: vec!["sources/files/private.md".to_string()],
17776 required: true,
17777 };
17778 let current = V2BaselineAsset {
17779 blob_sha256: local.sha256.clone(),
17780 bytes: local.bytes,
17781 media_type: local.media_type.clone(),
17782 wrappers: local.wrappers.clone(),
17783 required: local.required,
17784 disposition: "hosted".to_string(),
17785 leaf_hash: "d".repeat(64),
17786 };
17787 assert_eq!(
17788 v2_asset_withdrawal_operation(
17789 &store,
17790 &view,
17791 &local.path,
17792 &local,
17793 ¤t,
17794 "approved retention change",
17795 )
17796 .unwrap(),
17797 json!({
17798 "op": "asset_withdraw",
17799 "path": local.path,
17800 "expected": { "kind": "asset", "hash": "d".repeat(64) },
17801 "reason": "approved retention change",
17802 })
17803 );
17804
17805 let mut mismatched = current.clone();
17806 mismatched.required = false;
17807 assert!(matches!(
17808 v2_asset_withdrawal_operation(
17809 &store,
17810 &view,
17811 &local.path,
17812 &local,
17813 &mismatched,
17814 "approved retention change",
17815 ),
17816 Err(LinkError::InvalidPack { .. })
17817 ));
17818 }
17819
17820 #[test]
17821 fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
17822 let first = v2_checkout_id(None).unwrap();
17823 assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
17824 assert_ne!(first, v2_checkout_id(None).unwrap());
17825 assert!(is_sha256(&first));
17826 }
17827
17828 #[test]
17829 fn scoped_projection_edit_and_scope_transition_fail_closed() {
17830 let directory = tempfile::tempdir().unwrap();
17831 std::fs::write(
17832 directory.path().join("DB.md"),
17833 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
17834 )
17835 .unwrap();
17836 let store = Store::open_strict(directory.path()).unwrap();
17837 let head = scoped_test_head(&"a".repeat(64));
17838 let baseline = scoped_test_baseline(&"a".repeat(64));
17839 let mut view = v2_local_files(&store).unwrap();
17840 assert!(matches!(
17841 remove_scoped_projection(&head, Some(&baseline), &mut view),
17842 Err(LinkError::ScopedProjectionModified)
17843 ));
17844
17845 let changed = scoped_test_head(&"b".repeat(64));
17846 assert!(matches!(
17847 ensure_v2_view_compatible(&changed, Some(&baseline)),
17848 Err(LinkError::ScopedViewChanged)
17849 ));
17850
17851 let mut same_view_new_control = head.clone();
17852 same_view_new_control.control_revision = "c".repeat(64);
17853 assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
17854 assert!(!same_v2_head(&head, &same_view_new_control));
17855 }
17856
17857 #[test]
17858 fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
17859 let scoped = scoped_test_head(&"a".repeat(64));
17860 let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
17861 assert!(matches!(
17862 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
17863 Err(LinkError::ScopedProjectionModified)
17864 ));
17865
17866 let mut full = scoped.clone();
17867 full.view_kind = "full".to_string();
17868 let mut full_baseline = scoped_baseline.clone();
17869 full_baseline.view_kind = Some("full".to_string());
17870 full_baseline.projection_sha256 = None;
17871 assert!(matches!(
17872 ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
17873 Err(LinkError::InvalidPack { .. })
17874 ));
17875
17876 assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
17877 assert!(
17878 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
17879 );
17880 }
17881
17882 #[test]
17883 fn scoped_view_metadata_is_explicitly_non_authoritative() {
17884 let head = scoped_test_head(&"a".repeat(64));
17885 let value: Value =
17886 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
17887 assert_eq!(value["kind"], "link.md-scoped-view");
17888 assert_eq!(value["authoritative"], false);
17889 assert_eq!(value["visible_files"], 7);
17890 assert_eq!(value["brain"], TEST_BRAIN_ID);
17891 }
17892
17893 #[test]
17894 fn local_scoped_marker_requires_the_exact_generated_projection() {
17895 let directory = tempfile::tempdir().unwrap();
17896 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
17897 std::fs::write(
17898 directory.path().join("DB.md"),
17899 scoped_projection_bytes(TEST_BRAIN_ID),
17900 )
17901 .unwrap();
17902 let head = scoped_test_head(&"a".repeat(64));
17903 std::fs::write(
17904 directory.path().join(".dbmd/view.json"),
17905 scoped_view_metadata(&head, 0).unwrap(),
17906 )
17907 .unwrap();
17908 let store = Store::open_strict(directory.path()).unwrap();
17909 assert!(has_verified_local_scoped_view(&store));
17910
17911 std::fs::write(
17912 directory.path().join("DB.md"),
17913 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
17914 )
17915 .unwrap();
17916 let altered = Store::open_strict(directory.path()).unwrap();
17917 assert!(!has_verified_local_scoped_view(&altered));
17918 }
17919
17920 fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
17921 use ring::signature::KeyPair as _;
17922
17923 let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
17924 let rng = ring::rand::SystemRandom::new();
17925 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17926 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17927 let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
17928 let public_key = URL_SAFE_NO_PAD.encode(&public_der);
17929 let fingerprint = format!("{:x}", Sha256::digest(&public_der));
17930 let blob = b"new";
17931 let blob_hash = content_sha256(blob);
17932 let changes = json!({
17933 "mutation_id": "sync:proposal-fixture",
17934 "operations": [{
17935 "blob": blob_hash,
17936 "bytes": blob.len(),
17937 "expected": null,
17938 "op": "put",
17939 "path": "records/new.md",
17940 }],
17941 "reason": "fixture",
17942 "v": 2,
17943 });
17944 let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
17945 let changes_base64 = STANDARD.encode(&changes_bytes);
17946 let descriptor = json!({
17947 "base": null,
17948 "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
17949 "changes_base64": changes_base64,
17950 "rebase": "strict",
17951 "v": 2,
17952 });
17953 let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
17954 let payload_hash = "b".repeat(64);
17955 let submitted_at = "2026-08-19T12:00:00.000Z";
17956 let claim = json!({
17957 "actor_root": {
17958 "actor_class": "foreign_key",
17959 "credential": "ed25519:fixture",
17960 "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
17961 "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
17962 "principal": "key:fixture",
17963 "role": null,
17964 },
17965 "brain": TEST_BRAIN_ID,
17966 "clear_sha256": clear_hash,
17967 "control_revision": "c".repeat(64),
17968 "mutation_id": "sync:proposal-fixture",
17969 "payload_sha256": payload_hash,
17970 "proposal_id": proposal_id,
17971 "submitted_at": submitted_at,
17972 "v": 2,
17973 });
17974 let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
17975 let envelope = json!({
17976 "claim": claim,
17977 "fingerprint": fingerprint,
17978 "public_key": public_key,
17979 "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
17980 });
17981 let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
17982 let submission_hash =
17983 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
17984 let mut head = scoped_test_head(&"c".repeat(64));
17985 head.view_kind = "full".to_string();
17986 head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
17987 let value = json!({
17988 "proposal": {
17989 "base": null,
17990 "blobs": [{
17991 "bytes": blob.len(),
17992 "endpoint": format!(
17993 "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
17994 ),
17995 "sha256": blob_hash,
17996 }],
17997 "changes_base64": changes_base64,
17998 "clear_sha256": clear_hash,
17999 "expires_at": "2026-08-26T12:00:00.000Z",
18000 "id": proposal_id,
18001 "payload_sha256": payload_hash,
18002 "proposer": { "class": "foreign_key" },
18003 "rebase": "strict",
18004 "state": "pending",
18005 "submission_claim_base64": STANDARD.encode(envelope_bytes),
18006 "submission_claim_sha256": submission_hash,
18007 "submitted_at": submitted_at,
18008 },
18009 "v": 2,
18010 });
18011 (head, proposal_id, value)
18012 }
18013
18014 #[test]
18015 fn v2_proposal_verifier_accepts_exact_signed_payload() {
18016 let (head, proposal_id, value) = signed_proposal_fixture();
18017 let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
18018 assert_eq!(verified.blobs.len(), 1);
18019 assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
18020 }
18021
18022 #[test]
18023 fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
18024 let (head, proposal_id, value) = signed_proposal_fixture();
18025
18026 let mut changed = value.clone();
18027 changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
18028 assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
18029
18030 let mut redirected = value.clone();
18031 redirected["proposal"]["blobs"][0]["endpoint"] =
18032 Value::String("https://attacker.example/blob".to_string());
18033 assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
18034
18035 let mut forged = value;
18036 let encoded = forged["proposal"]["submission_claim_base64"]
18037 .as_str()
18038 .unwrap();
18039 let mut envelope: Value =
18040 serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
18041 envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
18042 let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18043 forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
18044 forged["proposal"]["submission_claim_sha256"] = Value::String(
18045 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
18046 );
18047 assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
18048 }
18049
18050 #[cfg(unix)]
18051 #[test]
18052 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
18053 let sandbox = tempfile::tempdir().unwrap();
18054 let destination = sandbox.path().join("brain");
18055 let entries = vec![
18056 (
18057 "DB.md".to_string(),
18058 scoped_projection_bytes(TEST_BRAIN_ID),
18059 ),
18060 (
18061 "records/contacts/a.md".to_string(),
18062 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
18063 .to_vec(),
18064 ),
18065 ];
18066 install_pulled_delta(&destination, &entries, &[], true).unwrap();
18067 assert!(destination.join("index.md").is_file());
18068 assert!(destination.join("records/index.md").is_file());
18069 assert!(destination.join("records/contacts/index.md").is_file());
18070 assert!(destination.join("records/contacts/index.jsonl").is_file());
18071 }
18072
18073 #[cfg(unix)]
18074 #[test]
18075 fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
18076 let sandbox = tempfile::tempdir().unwrap();
18077 let destination = sandbox.path().join("brain");
18078 let cache = sandbox.path().join("cache");
18079 std::fs::create_dir(&cache).unwrap();
18080 let db = scoped_projection_bytes(TEST_BRAIN_ID);
18081 let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
18082 let db_source = cache.join("db");
18083 let shared_source = cache.join("shared");
18084 crate::fsx::write_atomic(&db_source, &db).unwrap();
18085 crate::fsx::write_atomic(&shared_source, shared).unwrap();
18086 let mut entries = vec![V2StagedFile {
18087 path: "DB.md".to_string(),
18088 source: db_source,
18089 sha256: content_sha256(&db),
18090 bytes: db.len() as u64,
18091 }];
18092 for index in 0..512 {
18093 entries.push(V2StagedFile {
18094 path: format!("records/items/{index:05}.md"),
18095 source: shared_source.clone(),
18096 sha256: content_sha256(shared),
18097 bytes: shared.len() as u64,
18098 });
18099 }
18100 install_pulled_delta_sources(
18101 &destination,
18102 &entries,
18103 &[],
18104 false,
18105 None,
18106 &scoped_test_head(&"c".repeat(64)),
18107 )
18108 .unwrap();
18109 assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
18110 for index in 0..512 {
18111 assert_eq!(
18112 std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
18113 shared
18114 );
18115 }
18116 assert!(
18117 std::fs::read_dir(sandbox.path())
18118 .unwrap()
18119 .all(|entry| !entry
18120 .unwrap()
18121 .file_name()
18122 .to_string_lossy()
18123 .contains("pull-stage")),
18124 "the private stage must be atomically installed or removed"
18125 );
18126 }
18127
18128 #[cfg(unix)]
18129 #[test]
18130 fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
18131 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
18132
18133 let sandbox = tempfile::tempdir().unwrap();
18134 let root = sandbox.path().join("brain");
18135 std::fs::create_dir_all(root.join("records/items")).unwrap();
18136 let db = scoped_projection_bytes(TEST_BRAIN_ID);
18137 let old = b"---\ntype: note\n---\n\nold\n";
18138 let new = b"---\ntype: note\n---\n\nnew\n";
18139 let removed = b"---\ntype: note\n---\n\nremove me\n";
18140 std::fs::write(root.join("DB.md"), &db).unwrap();
18141 std::fs::write(root.join("records/items/change.md"), old).unwrap();
18142 std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
18143 for index in 0..512 {
18144 std::fs::write(
18145 root.join(format!("records/items/untouched-{index:04}.md")),
18146 old,
18147 )
18148 .unwrap();
18149 }
18150 let untouched = root.join("records/items/untouched-0256.md");
18151 let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
18152 let source = sandbox.path().join("changed-source");
18153 crate::fsx::write_atomic(&source, new).unwrap();
18154 let same_source = sandbox.path().join("unchanged-source");
18155 crate::fsx::write_atomic(&same_source, old).unwrap();
18156 let same_entry = V2StagedFile {
18157 path: "records/items/change.md".to_string(),
18158 source: same_source,
18159 sha256: content_sha256(old),
18160 bytes: old.len() as u64,
18161 };
18162 let entry = V2StagedFile {
18163 path: "records/items/change.md".to_string(),
18164 source,
18165 sha256: content_sha256(new),
18166 bytes: new.len() as u64,
18167 };
18168 let head = scoped_test_head(&"c".repeat(64));
18169
18170 install_established_v2_delta(
18174 Store::open_strict(&root).unwrap(),
18175 &[same_entry],
18176 &["records/items/already-absent.md".to_string()],
18177 true,
18178 None,
18179 &head,
18180 )
18181 .unwrap();
18182 assert_eq!(
18183 std::fs::metadata(&untouched).unwrap().ino(),
18184 untouched_inode
18185 );
18186 assert!(!root.join(V2_PULL_JOURNAL).exists());
18187
18188 install_established_v2_delta(
18189 Store::open_strict(&root).unwrap(),
18190 &[entry],
18191 &["records/items/delete.md".to_string()],
18192 false,
18193 None,
18194 &head,
18195 )
18196 .unwrap();
18197 assert_eq!(
18198 std::fs::read(root.join("records/items/change.md")).unwrap(),
18199 new
18200 );
18201 assert!(!root.join("records/items/delete.md").exists());
18202 assert_eq!(
18203 std::fs::metadata(&untouched).unwrap().ino(),
18204 untouched_inode
18205 );
18206 assert!(root.join(V2_PULL_JOURNAL).is_file());
18207 assert_eq!(
18208 std::fs::metadata(root.join(V2_PULL_JOURNAL))
18209 .unwrap()
18210 .permissions()
18211 .mode()
18212 & 0o777,
18213 0o600
18214 );
18215 let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
18216 .unwrap()
18217 .unwrap();
18218 assert_eq!(
18219 std::fs::metadata(root.join(&journal.backup_dir))
18220 .unwrap()
18221 .permissions()
18222 .mode()
18223 & 0o777,
18224 0o700
18225 );
18226 for entry in &journal.entries {
18227 if let Some(backup) = &entry.backup {
18228 assert_eq!(
18229 std::fs::metadata(root.join(&journal.backup_dir).join(backup))
18230 .unwrap()
18231 .permissions()
18232 .mode()
18233 & 0o777,
18234 0o600
18235 );
18236 }
18237 }
18238
18239 let cfg = test_hub_config(
18240 "https://example.test".to_string(),
18241 sandbox.path().join("state"),
18242 );
18243 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18244 assert_eq!(
18245 std::fs::read(root.join("records/items/change.md")).unwrap(),
18246 old
18247 );
18248 assert_eq!(
18249 std::fs::read(root.join("records/items/delete.md")).unwrap(),
18250 removed
18251 );
18252 assert_eq!(
18253 std::fs::metadata(&untouched).unwrap().ino(),
18254 untouched_inode
18255 );
18256 assert!(!root.join(V2_PULL_JOURNAL).exists());
18257 }
18258
18259 #[test]
18260 fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
18261 let body = b"bounded bytes";
18262 let path = "records/example.md".to_string();
18263 let file = V2BaselineFile {
18264 sha256: content_sha256(body),
18265 bytes: body.len() as u64,
18266 proof: None,
18267 };
18268 let header = serde_json::to_vec(&json!({
18269 "bytes": body.len(),
18270 "path": path,
18271 "sha256": file.sha256,
18272 "v": 2,
18273 }))
18274 .unwrap();
18275 let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
18276 stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
18277 stream.extend_from_slice(&header);
18278 stream.extend_from_slice(body);
18279 stream.extend_from_slice(&0_u32.to_be_bytes());
18280 let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
18281 assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
18282
18283 let mut tampered = stream.clone();
18284 let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
18285 tampered[body_offset] ^= 1;
18286 assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
18287
18288 let mut trailing = stream;
18289 trailing.push(0);
18290 assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
18291 }
18292
18293 #[test]
18294 fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
18295 let sandbox = tempfile::TempDir::new().unwrap();
18296 let root = sandbox.path().join("brain");
18297 std::fs::create_dir_all(&root).unwrap();
18298 std::fs::write(
18299 root.join("DB.md"),
18300 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18301 )
18302 .unwrap();
18303 let store = Store::open_strict(&root).unwrap();
18304 let incomplete = crate::ulid::mint();
18305 store
18306 .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
18307 .unwrap();
18308 let expired = crate::ulid::mint();
18309 store
18310 .create_dir_all(&v2_conflict_relative(&expired, "files"))
18311 .unwrap();
18312 let plan = V2ConflictPlan {
18313 v: 2,
18314 class: "content_resolution_required".to_string(),
18315 bundle: expired.clone(),
18316 brain: TEST_BRAIN_ID.to_string(),
18317 origin: "https://example.test".to_string(),
18318 created_unix: 0,
18319 expires_unix: 0,
18320 base_seq: None,
18321 base_commit: None,
18322 remote_seq: 0,
18323 remote_commit: None,
18324 remote_content_root: None,
18325 view_kind: "full".to_string(),
18326 view_revision: "a".repeat(64),
18327 files: vec![V2ConflictFile {
18328 path: "records/value.md".to_string(),
18329 base: V2ConflictCoordinate {
18330 sha256: None,
18331 bytes: None,
18332 file: None,
18333 },
18334 local: V2ConflictCoordinate {
18335 sha256: None,
18336 bytes: None,
18337 file: None,
18338 },
18339 remote: V2ConflictCoordinate {
18340 sha256: None,
18341 bytes: None,
18342 file: None,
18343 },
18344 }],
18345 };
18346 let mut bytes = serde_json::to_vec(&plan).unwrap();
18347 bytes.push(b'\n');
18348 store
18349 .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
18350 .unwrap();
18351
18352 let listed = sync_conflicts(&root, false, false).unwrap();
18353 assert_eq!(listed["bundles"], 2);
18354 assert_eq!(listed["pruned"], 0);
18355 let pruned = sync_conflicts(&root, true, false).unwrap();
18356 assert_eq!(pruned["bundles"], 0);
18357 assert_eq!(pruned["pruned"], 2);
18358 assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
18359 assert!(!root.join(".dbmd/conflicts").join(expired).exists());
18360 }
18361
18362 #[test]
18363 fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
18364 let sandbox = tempfile::TempDir::new().unwrap();
18365 let root = sandbox.path().join("brain");
18366 std::fs::create_dir_all(&root).unwrap();
18367 std::fs::write(
18368 root.join("DB.md"),
18369 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18370 )
18371 .unwrap();
18372 let store = Store::open_strict(&root).unwrap();
18373 let bundle = crate::ulid::mint();
18374 store
18375 .create_dir_all(&v2_conflict_relative(&bundle, "files"))
18376 .unwrap();
18377 store
18378 .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
18379 .unwrap();
18380
18381 assert!(sync_conflicts(&root, true, false).is_err());
18382 assert!(sync_conflicts(&root, false, true).is_err());
18383 let pruned = sync_conflicts(&root, true, true).unwrap();
18384 assert_eq!(pruned["pruned"], 1);
18385 assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
18386 }
18387
18388 #[test]
18389 fn ready_pull_journal_rolls_back_exact_preimages() {
18390 let sandbox = tempfile::TempDir::new().unwrap();
18391 let root = sandbox.path().join("brain");
18392 std::fs::create_dir_all(root.join("records")).unwrap();
18393 std::fs::write(
18394 root.join("DB.md"),
18395 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18396 )
18397 .unwrap();
18398 let path = "records/value.md";
18399 let old = b"---\ntype: note\n---\n\nold\n";
18400 let new = b"---\ntype: note\n---\n\nnew\n";
18401 std::fs::write(root.join(path), old).unwrap();
18402 let store = Store::open_strict(&root).unwrap();
18403 let bundle = crate::ulid::mint();
18404 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18405 store
18406 .create_private_dir_all(Path::new(&backup_dir))
18407 .unwrap();
18408 store
18409 .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
18410 .unwrap();
18411 let journal = V2PullJournal {
18412 v: 1,
18413 phase: V2PullPhase::Ready,
18414 brain: TEST_BRAIN_ID.to_string(),
18415 previous: V2PullCoordinate {
18416 head_seq: None,
18417 commit_hash: None,
18418 view_kind: None,
18419 view_revision: None,
18420 },
18421 next: V2PullCoordinate {
18422 head_seq: Some(2),
18423 commit_hash: Some("c".repeat(64)),
18424 view_kind: Some("full".to_string()),
18425 view_revision: Some("d".repeat(64)),
18426 },
18427 backup_dir: backup_dir.clone(),
18428 entries: vec![V2PullJournalEntry {
18429 path: path.to_string(),
18430 old: Some(V2PullFileCoordinate {
18431 sha256: content_sha256(old),
18432 bytes: old.len() as u64,
18433 }),
18434 new: Some(V2PullFileCoordinate {
18435 sha256: content_sha256(new),
18436 bytes: new.len() as u64,
18437 }),
18438 backup: Some("00000000".to_string()),
18439 }],
18440 };
18441 validate_v2_pull_journal(&journal).unwrap();
18442 store
18443 .write_private_atomic_new(
18444 Path::new(V2_PULL_JOURNAL),
18445 &v2_pull_journal_bytes(&journal).unwrap(),
18446 )
18447 .unwrap();
18448 store.write_atomic(Path::new(path), new).unwrap();
18449
18450 let cfg = test_hub_config(
18451 "https://example.test".to_string(),
18452 sandbox.path().join("state"),
18453 );
18454 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18455 assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
18456 assert!(!root.join(V2_PULL_JOURNAL).exists());
18457 assert!(!root.join(backup_dir).exists());
18458 }
18459
18460 #[test]
18461 fn preparing_pull_journal_discards_only_private_staging() {
18462 let sandbox = tempfile::TempDir::new().unwrap();
18463 let root = sandbox.path().join("brain");
18464 std::fs::create_dir_all(root.join(".dbmd")).unwrap();
18465 std::fs::write(
18466 root.join("DB.md"),
18467 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18468 )
18469 .unwrap();
18470 let store = Store::open_strict(&root).unwrap();
18471 let bundle = crate::ulid::mint();
18472 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18473 store
18474 .create_private_dir_all(Path::new(&backup_dir))
18475 .unwrap();
18476 let journal = V2PullJournal {
18477 v: 1,
18478 phase: V2PullPhase::Preparing,
18479 brain: TEST_BRAIN_ID.to_string(),
18480 previous: V2PullCoordinate {
18481 head_seq: None,
18482 commit_hash: None,
18483 view_kind: None,
18484 view_revision: None,
18485 },
18486 next: V2PullCoordinate {
18487 head_seq: Some(1),
18488 commit_hash: Some("a".repeat(64)),
18489 view_kind: Some("full".to_string()),
18490 view_revision: Some("b".repeat(64)),
18491 },
18492 backup_dir: backup_dir.clone(),
18493 entries: vec![V2PullJournalEntry {
18494 path: "records/new.md".to_string(),
18495 old: None,
18496 new: Some(V2PullFileCoordinate {
18497 sha256: "c".repeat(64),
18498 bytes: 1,
18499 }),
18500 backup: None,
18501 }],
18502 };
18503 store
18504 .write_private_atomic_new(
18505 Path::new(V2_PULL_JOURNAL),
18506 &v2_pull_journal_bytes(&journal).unwrap(),
18507 )
18508 .unwrap();
18509 let cfg = test_hub_config(
18510 "https://example.test".to_string(),
18511 sandbox.path().join("state"),
18512 );
18513
18514 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18515
18516 assert!(root.join("DB.md").is_file());
18517 assert!(!root.join(V2_PULL_JOURNAL).exists());
18518 assert!(!root.join(backup_dir).exists());
18519 }
18520
18521 #[test]
18522 fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
18523 let sandbox = tempfile::TempDir::new().unwrap();
18524 let root = sandbox.path().join("brain");
18525 std::fs::create_dir_all(root.join("records")).unwrap();
18526 std::fs::write(
18527 root.join("DB.md"),
18528 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18529 )
18530 .unwrap();
18531 let new = b"---\ntype: note\n---\n\nnew\n";
18532 std::fs::write(root.join("records/value.md"), new).unwrap();
18533 let store = Store::open_strict(&root).unwrap();
18534 let bundle = crate::ulid::mint();
18535 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18536 store
18537 .create_private_dir_all(Path::new(&backup_dir))
18538 .unwrap();
18539 let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
18540 store.create_private_dir_all(Path::new(&orphan)).unwrap();
18541 let next = V2PullCoordinate {
18542 head_seq: Some(2),
18543 commit_hash: Some("c".repeat(64)),
18544 view_kind: Some("full".to_string()),
18545 view_revision: Some("d".repeat(64)),
18546 };
18547 let journal = V2PullJournal {
18548 v: 1,
18549 phase: V2PullPhase::Ready,
18550 brain: TEST_BRAIN_ID.to_string(),
18551 previous: V2PullCoordinate {
18552 head_seq: Some(1),
18553 commit_hash: Some("a".repeat(64)),
18554 view_kind: Some("full".to_string()),
18555 view_revision: Some("b".repeat(64)),
18556 },
18557 next: next.clone(),
18558 backup_dir: backup_dir.clone(),
18559 entries: vec![V2PullJournalEntry {
18560 path: "records/value.md".to_string(),
18561 old: Some(V2PullFileCoordinate {
18562 sha256: "e".repeat(64),
18563 bytes: new.len() as u64,
18564 }),
18565 new: Some(V2PullFileCoordinate {
18566 sha256: content_sha256(new),
18567 bytes: new.len() as u64,
18568 }),
18569 backup: Some("00000000".to_string()),
18570 }],
18571 };
18572 store
18573 .write_private_atomic_new(
18574 Path::new(V2_PULL_JOURNAL),
18575 &v2_pull_journal_bytes(&journal).unwrap(),
18576 )
18577 .unwrap();
18578 let cfg = test_hub_config(
18579 "https://example.test".to_string(),
18580 sandbox.path().join("state"),
18581 );
18582 save_v2_baseline(
18583 &cfg,
18584 TEST_BRAIN_ID,
18585 &root,
18586 &V2SyncBaseline {
18587 v: 2,
18588 origin: "https://example.test".to_string(),
18589 brain: TEST_BRAIN_ID.to_string(),
18590 checkout_id: Some("c".repeat(64)),
18591 head_seq: next.head_seq,
18592 commit_hash: next.commit_hash.clone(),
18593 content_root: Some("f".repeat(64)),
18594 asset_root: None,
18595 assets: Default::default(),
18596 view_kind: next.view_kind.clone(),
18597 view_revision: next.view_revision.clone(),
18598 projection_sha256: None,
18599 files: Default::default(),
18600 local_policy_digest: None,
18601 local_eligibility: Default::default(),
18602 remote_copy_remains: Default::default(),
18603 },
18604 )
18605 .unwrap();
18606
18607 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18608
18609 assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
18610 assert!(!root.join(V2_PULL_JOURNAL).exists());
18611 assert!(!root.join(backup_dir).exists());
18612 assert!(!root.join(orphan).exists());
18613 }
18614}