1use std::collections::{BTreeMap, BTreeSet};
61use std::io::{Cursor, Read, Write};
62use std::path::{Path, PathBuf};
63use std::time::{SystemTime, UNIX_EPOCH};
64
65use base64::{
66 engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
67 Engine as _,
68};
69use ring::signature::{UnparsedPublicKey, ED25519};
70use serde::{Deserialize, Serialize};
71use serde_json::{json, Value};
72use sha2::{Digest, Sha256};
73
74use crate::store::Store;
75
76pub const HUB_URL_ENV: &str = "DBMD_HUB_URL";
78
79pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
82
83pub const HUB_CREDENTIAL_ORIGIN_ENV: &str = "DBMD_HUB_CREDENTIAL_ORIGIN";
87
88pub const STATE_DIR_ENV: &str = "DBMD_STATE_DIR";
92
93pub const ALLOW_PRIVATE_REGISTRY_HOME_ENV: &str = "DBMD_ALLOW_PRIVATE_REGISTRY_HOME";
97
98pub const ALLOW_PRIVATE_OBJECT_URL_ENV: &str = "DBMD_ALLOW_PRIVATE_OBJECT_URL";
102
103pub const BRAIN_KEY_FILE_ENV: &str = "DBMD_BRAIN_KEY_FILE";
109
110pub const AGENT_KEY_FILE_ENV: &str = "DBMD_AGENT_KEY_FILE";
118
119pub const CONFIG_REL_PATH: &str = ".dbmd/config";
122
123const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024;
126const MAX_FEED_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
129const MAX_REGISTRY_CARD_BYTES: u64 = 1024 * 1024;
131
132const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
135const MAX_STAGED_CHANGE_BYTES: usize = 64 * 1024 * 1024;
138
139const MAX_PUSH_FILES: usize = u16::MAX as usize;
141const MAX_STORE_PATH_BYTES: usize = 1_024;
142const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
143const MAX_ASSET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
148const MAX_PACK_BYTES: u64 =
151 MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
152const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
161const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
162
163const MAX_IDENTITY_ROTATIONS: usize = 1_024;
166
167fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
170 let mut batches: Vec<Vec<Value>> = Vec::new();
171 let mut current: Vec<Value> = Vec::new();
172 let mut current_bytes = 0usize;
173 for declaration in declarations {
174 let declared_bytes = serde_json::to_string(&declaration)
175 .map(|text| text.len())
176 .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
177 + 1;
178 if !current.is_empty()
179 && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
180 || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
181 {
182 batches.push(std::mem::take(&mut current));
183 current_bytes = 0;
184 }
185 current_bytes += declared_bytes;
186 current.push(declaration);
187 }
188 if !current.is_empty() {
189 batches.push(current);
190 }
191 batches
192}
193const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
197const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
198const FEED_PAGE_LIMIT: usize = 100;
199
200pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
205
206const CONNECT_TIMEOUT_SECS: u64 = 10;
209const READ_TIMEOUT_SECS: u64 = 120;
210const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
214const COMMIT_REQUEST_TIMEOUT_SECS: u64 = 900;
221const COMMIT_ATTEMPTS: usize = 4;
225const COMMIT_RETRY_BACKOFF_MS: [u64; COMMIT_ATTEMPTS - 1] = [5_000, 20_000, 45_000];
226const CONNECT_ATTEMPTS: usize = 3;
227const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
228const SAFE_READ_ATTEMPTS: usize = 4;
233const SAFE_READ_RETRY_BACKOFF_MS: [u64; SAFE_READ_ATTEMPTS - 1] = [200, 1_000, 3_000];
234
235const UPLOAD_ATTEMPTS: usize = 6;
239const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
240const UPLOAD_TOTAL_TIMEOUT_SECS: u64 = 300;
244
245fn upload_retry_backoff_ms(attempt: usize) -> u64 {
246 UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
247}
248
249fn upload_deadline_error() -> LinkError {
250 LinkError::Transport {
251 hub: "the object store".to_string(),
252 message: "network error (upload deadline exceeded)".to_string(),
253 }
254}
255
256fn upload_attempt_timeout(deadline: std::time::Instant) -> LinkResult<std::time::Duration> {
257 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
258 if remaining.is_zero() {
259 return Err(upload_deadline_error());
260 }
261 Ok(remaining.min(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS)))
262}
263
264fn wait_for_upload_retry(deadline: std::time::Instant, attempt: usize) -> bool {
265 if attempt + 1 >= UPLOAD_ATTEMPTS {
266 return false;
267 }
268 let pause = std::time::Duration::from_millis(upload_retry_backoff_ms(attempt));
269 if deadline.saturating_duration_since(std::time::Instant::now()) <= pause {
270 return false;
271 }
272 std::thread::sleep(pause);
273 true
274}
275
276const RESERVATION_ATTEMPTS: usize = 7;
281const RESERVATION_BACKOFF_MS: [u64; RESERVATION_ATTEMPTS - 1] =
282 [500, 2_000, 5_000, 15_000, 30_000, 60_000];
283
284fn is_retryable_hub_status(status: u16) -> bool {
288 matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
289}
290
291fn is_retryable_upload_status(status: u16) -> bool {
295 matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
296}
297const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
301#[cfg(unix)]
305const V2_PULL_INSTALL_WORKERS: usize = 16;
306const V2_BULK_STREAM_FILES: usize = 256;
310const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
311const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
312const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
313const V2_DOWNLOAD_CAPABILITY_FILES: usize = V2_BLOB_DOWNLOAD_WORKERS;
318const V2_DOWNLOAD_CAPABILITY_BYTES: u64 = 512 * 1024 * 1024;
319const V2_DOWNLOAD_CAPABILITY_ATTEMPTS: usize = 4;
320const V2_DOWNLOAD_CAPABILITY_BACKOFF_MS: [u64; V2_DOWNLOAD_CAPABILITY_ATTEMPTS - 1] =
321 [200, 1_000, 3_000];
322
323#[derive(Debug, thiserror::Error)]
327pub enum LinkError {
328 #[error(
330 "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
331 )]
332 NoHub,
333
334 #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
336 NoCredential,
337
338 #[error(
341 "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
342 )]
343 BadKey,
344
345 #[error(
351 "refusing to send an ambient credential to the hub selected by {CONFIG_REL_PATH} — set {HUB_CREDENTIAL_ORIGIN_ENV} to that exact origin, or choose the hub explicitly with --hub/{HUB_URL_ENV}"
352 )]
353 UnboundCredential,
354
355 #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
359 BadAgentKey {
360 message: String,
362 },
363
364 #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
366 UnsafeHub {
367 hub: String,
369 },
370
371 #[error("hub unreachable at {hub}: {message}")]
373 Transport {
374 hub: String,
376 message: String,
378 },
379
380 #[error("{what} failed (HTTP {status}): {message}")]
382 Http {
383 what: &'static str,
385 status: u16,
387 message: String,
389 code: Option<String>,
391 details: Option<Value>,
393 },
394
395 #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
398 NotJson {
399 what: &'static str,
401 status: u16,
403 },
404
405 #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
407 ResponseTooLarge {
408 limit_bytes: u64,
410 },
411
412 #[error("invalid address `{given}`: {reason}")]
414 BadAddress {
415 given: String,
417 reason: String,
419 },
420
421 #[error(
423 "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
424 )]
425 BadGrantId {
426 given: String,
428 },
429
430 #[error("refusing unsafe path from the hub: `{path}`")]
434 UnsafePath {
435 path: String,
437 },
438
439 #[error(
441 "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
442 MAX_STORE_BYTES / (1024 * 1024),
443 MAX_PACK_BYTES / (1024 * 1024)
444 )]
445 PushTooLarge {
446 detail: String,
448 },
449
450 #[error(
452 "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
453 MAX_PROPOSE_BYTES / 1024
454 )]
455 ProposeTooLarge {
456 bytes: u64,
458 },
459
460 #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
462 NotUtf8 {
463 path: String,
465 },
466
467 #[error("invalid store pack: {message}")]
469 InvalidPack {
470 message: String,
472 },
473
474 #[error("invalid signed feed: {message}")]
476 InvalidFeed {
477 message: String,
479 },
480
481 #[error(
485 "brain alias `{alias}` was pinned to `{from}` but now resolves to `{to}` — review both ids, then run `dbmd sync {alias} rebind --from {from} --to {to}`"
486 )]
487 AliasRebindRequired {
488 alias: String,
489 from: String,
490 to: String,
491 },
492
493 #[error("sync conflict on {paths:?} — resolve the named files and retry")]
496 Conflict {
497 paths: Vec<String>,
499 },
500
501 #[error(
505 "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
506 )]
507 ConflictBundle {
508 bundle: String,
510 paths: Vec<String>,
512 },
513
514 #[error(
518 "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
519 )]
520 LocalPolicyTransition {
521 paths: Vec<String>,
523 },
524
525 #[error(
529 "hosted assets require explicit withdrawal {paths:?} — retry with one --withdraw-from-hosting <path> per asset and a non-empty --withdraw-reason"
530 )]
531 AssetWithdrawalRequired {
532 paths: Vec<String>,
534 },
535
536 #[error(
541 "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
542 )]
543 BulkPreviewRequired {
544 preview: Value,
546 },
547
548 #[error(
551 "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
552 )]
553 ScopedProjectionModified,
554
555 #[error(
559 "the checkout's permission scope changed — clone into a new directory to accept the new view"
560 )]
561 ScopedViewChanged,
562
563 #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
566 BrainUnavailable,
567
568 #[error(
571 "the remote brain advanced during sync — retry to converge from the new verified head"
572 )]
573 RemoteAdvancedDuringSync,
574
575 #[error(
578 "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
579 )]
580 UnsupportedPlatform {
581 operation: &'static str,
583 },
584
585 #[error(transparent)]
587 Io(#[from] std::io::Error),
588
589 #[error(transparent)]
591 Store(#[from] crate::StoreError),
592}
593
594pub type LinkResult<T> = std::result::Result<T, LinkError>;
596
597#[derive(Debug, Clone, PartialEq, Eq)]
599pub struct V2BulkConfirmation {
600 pub id: String,
602 pub digest: String,
605}
606
607impl V2BulkConfirmation {
608 pub fn parse(value: &str) -> LinkResult<Self> {
611 let (id, digest) = value
612 .split_once(':')
613 .ok_or_else(|| LinkError::InvalidPack {
614 message: "bulk confirmation must be <id>:<digest>".to_string(),
615 })?;
616 if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
617 return Err(LinkError::InvalidPack {
618 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
619 .to_string(),
620 });
621 }
622 Ok(Self {
623 id: id.to_string(),
624 digest: digest.to_string(),
625 })
626 }
627}
628
629fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
634 #[cfg(any(target_os = "linux", target_os = "macos", windows))]
635 {
636 let _ = operation;
637 Ok(())
638 }
639 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
640 {
641 Err(LinkError::UnsupportedPlatform { operation })
642 }
643}
644
645#[derive(Debug, Clone, PartialEq, Eq)]
651pub enum AddressTarget {
652 Id(String),
654 Path(String),
658}
659
660const BAD_BRAIN_REASON: &str =
663 "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
664
665const BAD_TARGET_REASON: &str =
668 "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
669
670#[derive(Debug, Clone, PartialEq, Eq)]
675pub struct Address {
676 pub brain: String,
678 pub target: Option<AddressTarget>,
680}
681
682impl Address {
683 pub fn parse(raw: &str) -> LinkResult<Address> {
687 let bad = |reason: &str| LinkError::BadAddress {
688 given: raw.to_string(),
689 reason: reason.to_string(),
690 };
691
692 let trimmed = raw.trim();
693 let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
694 if body.is_empty() {
695 return Err(bad("empty address"));
696 }
697
698 let (brain, rest) = match body.split_once('/') {
699 Some((b, r)) => (b, Some(r)),
700 None => (body, None),
701 };
702
703 if brain.is_empty() {
704 return Err(bad("missing brain reference before `/`"));
705 }
706 if !is_safe_ref(brain) {
707 return Err(bad(BAD_BRAIN_REASON));
708 }
709
710 let target = match rest {
711 None => None,
712 Some("") => return Err(bad("trailing `/` with no record id or path")),
713 Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
714 Some(r) => {
715 if !safe_store_rel_path(r) || !r.ends_with(".md") {
716 return Err(bad(BAD_TARGET_REASON));
717 }
718 Some(AddressTarget::Path(r.to_string()))
719 }
720 };
721
722 Ok(Address {
723 brain: brain.to_string(),
724 target,
725 })
726 }
727}
728
729fn is_safe_ref(s: &str) -> bool {
732 !s.is_empty()
733 && s.len() <= 64
734 && s.bytes()
735 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
736}
737
738pub fn is_valid_handle(s: &str) -> bool {
741 is_safe_ref(s)
742}
743
744pub fn safe_store_rel_path(p: &str) -> bool {
750 if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
751 return false;
752 }
753 if !p
754 .bytes()
755 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
756 {
757 return false;
758 }
759 p.split('/')
760 .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
761}
762
763fn require_safe_ref(brain: &str) -> LinkResult<()> {
771 if is_safe_ref(brain) {
772 Ok(())
773 } else {
774 Err(LinkError::BadAddress {
775 given: brain.to_string(),
776 reason: BAD_BRAIN_REASON.to_string(),
777 })
778 }
779}
780
781fn require_valid_handle(handle: &str) -> LinkResult<()> {
783 if is_valid_handle(handle) {
784 Ok(())
785 } else {
786 Err(LinkError::BadAddress {
787 given: handle.to_string(),
788 reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
789 })
790 }
791}
792
793fn require_safe_grant_id(id: &str) -> LinkResult<()> {
797 if is_safe_ref(id) {
798 Ok(())
799 } else {
800 Err(LinkError::BadGrantId {
801 given: id.to_string(),
802 })
803 }
804}
805
806#[derive(Debug, Clone)]
812pub struct HubConfig {
813 pub hub: String,
815 pub key: Option<String>,
817 pub agent_key: Option<AgentSigningKey>,
820 pub brain_key: Option<AgentSigningKey>,
823 pub state_dir: PathBuf,
826 store_selected: bool,
829}
830
831#[derive(Clone)]
834pub struct AgentSigningKey {
835 pkcs8: Vec<u8>,
836 pub multikey: String,
838 pub public_key_spki: String,
840}
841
842impl std::fmt::Debug for AgentSigningKey {
843 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
844 f.debug_struct("AgentSigningKey")
845 .field("multikey", &self.multikey)
846 .field("pkcs8", &"<redacted>")
847 .finish()
848 }
849}
850
851impl HubConfig {
852 pub fn require_key(&self) -> LinkResult<&str> {
855 self.key.as_deref().ok_or(LinkError::NoCredential)
856 }
857}
858
859pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
864 let explicit_hub = flag_hub
865 .map(str::to_string)
866 .or_else(|| env_nonempty(HUB_URL_ENV));
867 let selected_by_store = explicit_hub.is_none();
868 let hub = explicit_hub
869 .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
870 .ok_or(LinkError::NoHub)?;
871 let hub = hub.trim().trim_end_matches('/').to_string();
872 assert_safe_hub(&hub)?;
873 if selected_by_store {
874 let parsed =
875 url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
876 if !parsed.scheme().eq_ignore_ascii_case("https")
880 || (parsed.path() != "/" && !parsed.path().is_empty())
881 {
882 return Err(LinkError::UnsafeHub { hub });
883 }
884 }
885
886 let key = match env_nonempty(HUB_KEY_ENV) {
887 Some(raw) => Some(clean_key(&raw)?),
888 None => None,
889 };
890
891 let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
892 Some(path) => Some(load_agent_key(Path::new(&path))?),
893 None => None,
894 };
895
896 let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
897 Some(path) => Some(load_agent_key(Path::new(&path))?),
898 None => None,
899 };
900
901 if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
908 let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
909 .and_then(|value| normalized_origin(&value).ok());
910 let selected_origin = normalized_origin(&hub)?;
911 if bound.as_deref() != Some(selected_origin.as_str()) {
912 return Err(LinkError::UnboundCredential);
913 }
914 }
915
916 Ok(HubConfig {
917 hub,
918 key,
919 agent_key,
920 brain_key,
921 state_dir: toolkit_state_dir()?,
922 store_selected: selected_by_store,
923 })
924}
925
926fn toolkit_state_dir() -> LinkResult<PathBuf> {
927 if let Some(path) = env_nonempty(STATE_DIR_ENV) {
928 let path = PathBuf::from(path);
929 if !path.is_absolute() {
930 return Err(LinkError::UnsafePath {
931 path: path.display().to_string(),
932 });
933 }
934 return Ok(path);
935 }
936 #[cfg(windows)]
937 if let Some(base) = env_nonempty("LOCALAPPDATA") {
938 let base = PathBuf::from(base);
939 if base.is_absolute() {
940 return Ok(base.join("dbmd").join("state"));
941 }
942 }
943 #[cfg(windows)]
944 {
945 Err(LinkError::Io(std::io::Error::new(
946 std::io::ErrorKind::NotFound,
947 format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
948 )))
949 }
950 #[cfg(not(windows))]
951 if let Some(base) = env_nonempty("XDG_STATE_HOME") {
952 let base = PathBuf::from(base);
953 if base.is_absolute() {
954 return Ok(base.join("dbmd"));
955 }
956 }
957 #[cfg(not(windows))]
958 let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
959 LinkError::Io(std::io::Error::new(
960 std::io::ErrorKind::NotFound,
961 format!("cannot locate user state; set {STATE_DIR_ENV}"),
962 ))
963 })?);
964 #[cfg(not(windows))]
965 if !home.is_absolute() {
966 return Err(LinkError::UnsafePath {
967 path: home.display().to_string(),
968 });
969 }
970 #[cfg(target_os = "macos")]
971 {
972 Ok(home
973 .join("Library")
974 .join("Application Support")
975 .join("dbmd")
976 .join("state"))
977 }
978 #[cfg(all(not(target_os = "macos"), not(windows)))]
979 {
980 Ok(home.join(".local").join("state").join("dbmd"))
981 }
982}
983
984fn normalized_origin(value: &str) -> LinkResult<String> {
985 let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
986 hub: value.to_string(),
987 })?;
988 if !(parsed.scheme().eq_ignore_ascii_case("https")
989 || parsed.scheme().eq_ignore_ascii_case("http"))
990 || !parsed.username().is_empty()
991 || parsed.password().is_some()
992 || (parsed.path() != "/" && !parsed.path().is_empty())
993 || parsed.query().is_some()
994 || parsed.fragment().is_some()
995 {
996 return Err(LinkError::UnsafeHub {
997 hub: value.to_string(),
998 });
999 }
1000 let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
1001 hub: value.to_string(),
1002 })?;
1003 let host = if host.contains(':') {
1004 format!("[{host}]")
1005 } else {
1006 host.to_ascii_lowercase()
1007 };
1008 let port = parsed
1009 .port_or_known_default()
1010 .ok_or_else(|| LinkError::UnsafeHub {
1011 hub: value.to_string(),
1012 })?;
1013 let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
1014 || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
1015 Ok(format!(
1016 "{}://{}{}",
1017 parsed.scheme().to_ascii_lowercase(),
1018 host,
1019 if default {
1020 String::new()
1021 } else {
1022 format!(":{port}")
1023 }
1024 ))
1025}
1026
1027const ED25519_SPKI_PREFIX: [u8; 12] = [
1034 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1035];
1036
1037fn bad_agent_key(message: &str) -> LinkError {
1038 LinkError::BadAgentKey {
1039 message: message.to_string(),
1040 }
1041}
1042
1043fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
1044 ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
1048 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
1049 .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
1050}
1051
1052fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
1054 use ring::signature::KeyPair as _;
1055 let mut spki = Vec::with_capacity(44);
1056 spki.extend_from_slice(&ED25519_SPKI_PREFIX);
1057 spki.extend_from_slice(pair.public_key().as_ref());
1058 (
1059 URL_SAFE_NO_PAD.encode(&spki),
1060 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
1061 )
1062}
1063
1064pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1068 load_agent_key(path)
1069}
1070
1071fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1073 #[cfg(unix)]
1074 let file = {
1075 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1076 use std::os::unix::ffi::OsStrExt as _;
1077 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1078 .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1079 let leaf = path
1080 .file_name()
1081 .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1082 let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1083 let fd = unsafe {
1084 libc::openat(
1085 parent.as_raw_fd(),
1086 leaf.as_ptr(),
1087 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1088 )
1089 };
1090 if fd < 0 {
1091 return Err(bad_agent_key(
1092 "the key path must be an existing regular file without symlink ancestors",
1093 ));
1094 }
1095 unsafe { std::fs::File::from_raw_fd(fd) }
1096 };
1097 #[cfg(not(unix))]
1098 let file = std::fs::File::open(path)
1099 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1100 let metadata = file
1101 .metadata()
1102 .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1103 if !metadata.is_file() {
1104 return Err(bad_agent_key("the key path must be a regular file"));
1105 }
1106 #[cfg(unix)]
1107 {
1108 use std::os::unix::fs::PermissionsExt as _;
1109 if metadata.permissions().mode() & 0o077 != 0 {
1110 return Err(bad_agent_key(
1111 "the key file is accessible to group/other; set mode 0600",
1112 ));
1113 }
1114 }
1115 let mut text = String::new();
1116 file.take(1024 * 1024 + 1)
1117 .read_to_string(&mut text)
1118 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1119 if text.len() > 1024 * 1024 {
1120 return Err(bad_agent_key("the key file exceeds the size limit"));
1121 }
1122 let pkcs8 = URL_SAFE_NO_PAD
1123 .decode(text.trim())
1124 .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1125 let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1126 Ok(AgentSigningKey {
1127 pkcs8,
1128 multikey,
1129 public_key_spki,
1130 })
1131}
1132
1133fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1139 #[cfg(unix)]
1140 let (mut file, parent, leaf) = {
1141 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1142 use std::os::unix::ffi::OsStrExt as _;
1143 let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1144 let leaf_name = path
1145 .file_name()
1146 .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1147 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1148 let fd = unsafe {
1149 libc::openat(
1150 parent.as_raw_fd(),
1151 leaf.as_ptr(),
1152 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1153 0o600,
1154 )
1155 };
1156 if fd < 0 {
1157 let error = std::io::Error::last_os_error();
1158 if error.kind() == std::io::ErrorKind::AlreadyExists {
1159 return Err(bad_agent_key(
1160 "the output file already exists — refusing to overwrite a key",
1161 ));
1162 }
1163 return Err(error.into());
1164 }
1165 (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1166 };
1167 #[cfg(not(unix))]
1168 let mut file = std::fs::OpenOptions::new()
1169 .write(true)
1170 .create_new(true)
1171 .open(path)
1172 .map_err(|error| {
1173 if error.kind() == std::io::ErrorKind::AlreadyExists {
1174 bad_agent_key("the output file already exists — refusing to overwrite a key")
1175 } else {
1176 LinkError::Io(error)
1177 }
1178 })?;
1179 if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1180 drop(file);
1181 #[cfg(unix)]
1182 let _ =
1183 unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1184 #[cfg(not(unix))]
1185 let _ = std::fs::remove_file(path);
1186 return Err(LinkError::Io(error));
1187 }
1188 drop(file);
1189 #[cfg(unix)]
1190 parent.sync_all()?;
1191 Ok(())
1192}
1193
1194#[derive(Debug, Serialize)]
1197pub struct GeneratedAgentKey {
1198 pub multikey: String,
1200 #[serde(rename = "publicKeySpki")]
1202 pub public_key_spki: String,
1203 #[serde(rename = "keyFile")]
1205 pub key_file: String,
1206}
1207
1208pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1213 require_hardened_filesystem("key generation")?;
1214 let rng = ring::rand::SystemRandom::new();
1215 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1216 .map_err(|_| bad_agent_key("key generation failed"))?;
1217 let pair = agent_keypair(pkcs8.as_ref())?;
1218 let (spki_b64u, multikey) = public_identity_for(&pair);
1219
1220 write_secret_new(
1221 out,
1222 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1223 )?;
1224
1225 Ok(GeneratedAgentKey {
1226 multikey,
1227 public_key_spki: spki_b64u,
1228 key_file: out.display().to_string(),
1229 })
1230}
1231
1232fn linkmd_sig_header(
1241 key: &AgentSigningKey,
1242 origin: &str,
1243 method: &str,
1244 path: &str,
1245 body: Option<&str>,
1246) -> LinkResult<String> {
1247 let ts = std::time::SystemTime::now()
1248 .duration_since(std::time::UNIX_EPOCH)
1249 .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1250 .as_secs();
1251 let body_hash = match body {
1252 Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1253 None => "-".to_string(),
1254 };
1255 let canonical = format!(
1256 "v2\n{}\n{}\n{}\n{}\n{}",
1257 origin,
1258 method.to_uppercase(),
1259 path,
1260 ts,
1261 body_hash
1262 );
1263 let pair = agent_keypair(&key.pkcs8)?;
1264 let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1265 let fingerprint = key.multikey.trim_start_matches("ed25519:");
1266 Ok(format!(
1267 "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1268 ))
1269}
1270
1271#[derive(Serialize)]
1278struct WireFeedFile {
1279 path: String,
1280 sha256: String,
1281 bytes: u64,
1282}
1283
1284#[derive(Serialize)]
1287struct UnsignedWireEntry<'a> {
1288 v: u8,
1289 seq: u64,
1290 ts: String,
1291 brain: &'a str,
1292 public_key: &'a str,
1293 kind: &'a str,
1294 op: &'a str,
1295 pack_sha256: &'a str,
1296 files: &'a [WireFeedFile],
1297 removed: &'a [String],
1298 prev_entry_hash: Option<&'a str>,
1299}
1300
1301fn self_custody_entry(
1307 key: &AgentSigningKey,
1308 seq: u64,
1309 ts: String,
1310 pack_sha256: &str,
1311 files: &[WireFeedFile],
1312 prev_entry_hash: Option<&str>,
1313) -> LinkResult<String> {
1314 let removed: [String; 0] = [];
1315 let unsigned = serde_json::to_string(&UnsignedWireEntry {
1316 v: 1,
1317 seq,
1318 ts,
1319 brain: &key.multikey,
1320 public_key: &key.public_key_spki,
1321 kind: "push",
1322 op: "snapshot",
1323 pack_sha256,
1324 files,
1325 removed: &removed,
1326 prev_entry_hash,
1327 })
1328 .expect("serialize feed entry");
1329 let pair = agent_keypair(&key.pkcs8)?;
1330 let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1331 Ok(format!(
1332 "{},\"sig\":\"{}\"}}",
1333 &unsigned[..unsigned.len() - 1],
1334 sig
1335 ))
1336}
1337
1338fn env_nonempty(name: &str) -> Option<String> {
1341 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1342}
1343
1344fn config_file_hub(path: &Path) -> Option<String> {
1349 const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1350 #[cfg(unix)]
1351 let file = {
1352 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1353 use std::os::unix::ffi::OsStrExt as _;
1354 let parent =
1355 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1356 let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1357 let fd = unsafe {
1358 libc::openat(
1359 parent.as_raw_fd(),
1360 leaf.as_ptr(),
1361 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1362 )
1363 };
1364 if fd < 0 {
1365 return None;
1366 }
1367 unsafe { std::fs::File::from_raw_fd(fd) }
1368 };
1369 #[cfg(not(unix))]
1370 let file = std::fs::File::open(path).ok()?;
1371 let metadata = file.metadata().ok()?;
1372 if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1373 return None;
1374 }
1375 let mut bytes = Vec::with_capacity(metadata.len() as usize);
1376 file.take(MAX_CONFIG_BYTES + 1)
1377 .read_to_end(&mut bytes)
1378 .ok()?;
1379 if bytes.len() as u64 > MAX_CONFIG_BYTES {
1380 return None;
1381 }
1382 let text = String::from_utf8(bytes).ok()?;
1383 for line in text.lines() {
1384 let line = line.trim();
1385 if line.is_empty() || line.starts_with('#') {
1386 continue;
1387 }
1388 if let Some((k, v)) = line.split_once('=') {
1389 if k.trim() == "hub" {
1390 let v = v.trim();
1391 if !v.is_empty() {
1392 return Some(v.to_string());
1393 }
1394 }
1395 }
1396 }
1397 None
1398}
1399
1400fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1403 let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1404 hub: hub.to_string(),
1405 })?;
1406 if !(parsed.scheme().eq_ignore_ascii_case("https")
1407 || parsed.scheme().eq_ignore_ascii_case("http"))
1408 || !parsed.username().is_empty()
1409 || parsed.password().is_some()
1410 || (parsed.path() != "/" && !parsed.path().is_empty())
1411 || parsed.query().is_some()
1412 || parsed.fragment().is_some()
1413 {
1414 return Err(LinkError::UnsafeHub {
1415 hub: hub.to_string(),
1416 });
1417 }
1418 let loopback = match parsed.host() {
1419 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1420 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1421 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1422 None => false,
1423 };
1424 if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1425 Ok(())
1426 } else {
1427 Err(LinkError::UnsafeHub {
1428 hub: hub.to_string(),
1429 })
1430 }
1431}
1432
1433fn clean_key(raw: &str) -> LinkResult<String> {
1438 let k = raw.trim();
1439 if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1440 return Err(LinkError::BadKey);
1441 }
1442 Ok(k.to_string())
1443}
1444
1445#[derive(Debug)]
1451pub struct HubResponse {
1452 pub status: u16,
1454 pub body: Option<Value>,
1456}
1457
1458struct RawHubResponse {
1459 status: u16,
1460 body: Vec<u8>,
1461}
1462
1463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1465enum Auth {
1466 Required,
1468 None,
1470 Optional,
1474}
1475
1476fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1477 ureq::AgentBuilder::new()
1478 .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1479 .redirects(0)
1483 .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1484 .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1485 .timeout_write(overall)
1486 .timeout(overall)
1487}
1488
1489fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1490 hub_agent_with_timeout(
1491 cfg,
1492 std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1493 )
1494}
1495
1496fn hub_agent_with_timeout(
1497 cfg: &HubConfig,
1498 overall: std::time::Duration,
1499) -> LinkResult<ureq::Agent> {
1500 if !cfg.store_selected {
1501 return Ok(agent_builder_with_timeout(overall).build());
1502 }
1503 let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1504 hub: cfg.hub.clone(),
1505 })?;
1506 pinned_public_agent_pooled(
1507 &parsed,
1508 false,
1509 "store-selected hub",
1510 AgentShape {
1511 overall,
1512 ..AgentShape::default()
1513 },
1514 )
1515}
1516
1517fn request_raw(
1522 cfg: &HubConfig,
1523 method: &str,
1524 path: &str,
1525 body: Option<&Value>,
1526 auth: Auth,
1527 max_response_bytes: u64,
1528) -> LinkResult<RawHubResponse> {
1529 let http = hub_agent(cfg)?;
1530 request_raw_with_agent(
1531 cfg,
1532 &http,
1533 method,
1534 path,
1535 body,
1536 RawRequestOptions {
1537 auth,
1538 max_response_bytes,
1539 request_id: None,
1540 retry_transport: false,
1541 },
1542 )
1543}
1544
1545fn request_raw_retryable_read(
1549 cfg: &HubConfig,
1550 method: &str,
1551 path: &str,
1552 body: Option<&Value>,
1553 auth: Auth,
1554 max_response_bytes: u64,
1555) -> LinkResult<RawHubResponse> {
1556 let http = hub_agent(cfg)?;
1557 request_raw_with_agent(
1558 cfg,
1559 &http,
1560 method,
1561 path,
1562 body,
1563 RawRequestOptions {
1564 auth,
1565 max_response_bytes,
1566 request_id: None,
1567 retry_transport: true,
1568 },
1569 )
1570}
1571
1572struct RawRequestOptions<'a> {
1573 auth: Auth,
1574 max_response_bytes: u64,
1575 request_id: Option<&'a str>,
1576 retry_transport: bool,
1577}
1578
1579fn request_raw_with_agent(
1580 cfg: &HubConfig,
1581 http: &ureq::Agent,
1582 method: &str,
1583 path: &str,
1584 body: Option<&Value>,
1585 options: RawRequestOptions<'_>,
1586) -> LinkResult<RawHubResponse> {
1587 let url = format!("{}{}", cfg.hub, path);
1588 let encoded_body = body.map(Value::to_string);
1589 let origin = normalized_origin(&cfg.hub)?;
1590 let safe_read = (method == "GET" && encoded_body.is_none()) || options.retry_transport;
1591 let mut read_attempt = 0;
1592 loop {
1593 let credential = match options.auth {
1600 Auth::Required => Some(match &cfg.agent_key {
1601 Some(key) => {
1602 linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?
1603 }
1604 None => format!("Bearer {}", cfg.require_key()?),
1605 }),
1606 Auth::Optional => match &cfg.agent_key {
1607 Some(key) => Some(linkmd_sig_header(
1608 key,
1609 &origin,
1610 method,
1611 path,
1612 encoded_body.as_deref(),
1613 )?),
1614 None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1615 },
1616 Auth::None => None,
1617 };
1618 let result = with_connect_retries(|| {
1619 let mut req = http.request(method, &url);
1620 if let Some(value) = &credential {
1621 req = req.set("authorization", value);
1622 }
1623 if let Some(value) = options.request_id {
1624 req = req.set("x-request-id", value);
1625 }
1626 match &encoded_body {
1627 Some(value) => req
1628 .set("content-type", "application/json")
1629 .send_string(value)
1630 .map_err(Box::new),
1631 None => req.call().map_err(Box::new),
1632 }
1633 });
1634 let resp = match result {
1635 Ok(resp) => resp,
1636 Err(error) => match *error {
1637 ureq::Error::Status(_, resp) => resp,
1638 ureq::Error::Transport(error) => {
1639 if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS {
1640 std::thread::sleep(std::time::Duration::from_millis(
1641 SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1642 ));
1643 read_attempt += 1;
1644 continue;
1645 }
1646 return Err(LinkError::Transport {
1647 hub: cfg.hub.clone(),
1648 message: error.to_string(),
1649 });
1650 }
1651 },
1652 };
1653
1654 let status = resp.status();
1655 let buf = match read_response_body(resp, options.max_response_bytes + 1, &cfg.hub) {
1656 Ok(buf) => buf,
1657 Err(LinkError::Transport { .. })
1658 if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS =>
1659 {
1660 std::thread::sleep(std::time::Duration::from_millis(
1661 SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1662 ));
1663 read_attempt += 1;
1664 continue;
1665 }
1666 Err(error) => return Err(error),
1667 };
1668 if buf.len() as u64 > options.max_response_bytes {
1669 return Err(LinkError::ResponseTooLarge {
1670 limit_bytes: options.max_response_bytes,
1671 });
1672 }
1673 return Ok(RawHubResponse { status, body: buf });
1674 }
1675}
1676
1677fn request_capped(
1678 cfg: &HubConfig,
1679 method: &str,
1680 path: &str,
1681 body: Option<&Value>,
1682 auth: Auth,
1683 max_response_bytes: u64,
1684) -> LinkResult<HubResponse> {
1685 let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1686 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1687 Ok(HubResponse {
1688 status: raw.status,
1689 body: parsed,
1690 })
1691}
1692
1693fn request_patient(
1705 cfg: &HubConfig,
1706 method: &str,
1707 path: &str,
1708 body: Option<&Value>,
1709 auth: Auth,
1710) -> LinkResult<HubResponse> {
1711 let http = hub_agent_with_timeout(
1712 cfg,
1713 std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1714 )?;
1715 let mut attempt = 0;
1716 loop {
1717 let sent = request_raw_with_agent(
1718 cfg,
1719 &http,
1720 method,
1721 path,
1722 body,
1723 RawRequestOptions {
1724 auth,
1725 max_response_bytes: MAX_RESPONSE_BYTES,
1726 request_id: None,
1727 retry_transport: false,
1728 },
1729 );
1730 match sent {
1731 Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1732 std::thread::sleep(std::time::Duration::from_millis(
1733 COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1734 ));
1735 attempt += 1;
1736 }
1737 Err(error) => return Err(error),
1738 Ok(raw) => {
1739 return Ok(HubResponse {
1740 status: raw.status,
1741 body: serde_json::from_slice(&raw.body).ok(),
1742 })
1743 }
1744 }
1745 }
1746}
1747
1748fn request(
1749 cfg: &HubConfig,
1750 method: &str,
1751 path: &str,
1752 body: Option<&Value>,
1753 auth: Auth,
1754) -> LinkResult<HubResponse> {
1755 request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1756}
1757
1758fn request_with_request_id(
1763 cfg: &HubConfig,
1764 method: &str,
1765 path: &str,
1766 body: Option<&Value>,
1767 auth: Auth,
1768 request_id: &str,
1769) -> LinkResult<HubResponse> {
1770 if request_id.is_empty()
1771 || request_id.len() > 128
1772 || !request_id
1773 .bytes()
1774 .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1775 {
1776 return Err(invalid_feed("hub returned an unsafe request id"));
1777 }
1778 let http = hub_agent_with_timeout(
1781 cfg,
1782 std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1783 )?;
1784 let raw = request_raw_with_agent(
1785 cfg,
1786 &http,
1787 method,
1788 path,
1789 body,
1790 RawRequestOptions {
1791 auth,
1792 max_response_bytes: MAX_RESPONSE_BYTES,
1793 request_id: Some(request_id),
1794 retry_transport: false,
1795 },
1796 )?;
1797 Ok(HubResponse {
1798 status: raw.status,
1799 body: serde_json::from_slice(&raw.body).ok(),
1800 })
1801}
1802
1803fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1804 if (200..300).contains(&r.status) {
1805 return Ok(r.body);
1806 }
1807 ensure_ok(
1808 HubResponse {
1809 status: r.status,
1810 body: serde_json::from_slice(&r.body).ok(),
1811 },
1812 what,
1813 )
1814 .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1815}
1816
1817fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1822 matches!(
1823 kind,
1824 ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1825 )
1826}
1827
1828fn with_connect_retries(
1829 mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1830) -> Result<ureq::Response, Box<ureq::Error>> {
1831 let mut attempt = 0;
1832 loop {
1833 match send() {
1834 Err(error)
1835 if matches!(
1836 error.as_ref(),
1837 ureq::Error::Transport(transport)
1838 if is_pre_request_transport(transport.kind())
1839 ) && attempt + 1 < CONNECT_ATTEMPTS =>
1840 {
1841 std::thread::sleep(std::time::Duration::from_millis(
1842 CONNECT_RETRY_BACKOFF_MS[attempt],
1843 ));
1844 attempt += 1;
1845 }
1846 result => return result,
1847 }
1848 }
1849}
1850
1851fn hub_is_loopback(hub: &str) -> bool {
1852 url::Url::parse(hub).ok().is_some_and(|parsed| {
1853 parsed.host().is_some_and(|host| match host {
1854 url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1855 url::Host::Ipv4(ip) => ip.is_loopback(),
1856 url::Host::Ipv6(ip) => ip.is_loopback(),
1857 })
1858 })
1859}
1860
1861fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1865 let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1866 message: "the hub returned an invalid object-store URL".to_string(),
1867 })?;
1868 let allow_private = hub_is_loopback(&cfg.hub)
1869 || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1870 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1871 || !parsed.username().is_empty()
1872 || parsed.password().is_some()
1873 || parsed.fragment().is_some()
1874 {
1875 return Err(LinkError::InvalidPack {
1876 message: "the hub returned an unsafe object-store URL".to_string(),
1877 });
1878 }
1879 Ok((parsed, allow_private))
1880}
1881
1882fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1883 let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1884 pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1885 LinkError::InvalidPack {
1886 message: "the hub returned an object-store URL with an unsafe network target"
1887 .to_string(),
1888 }
1889 })
1890}
1891
1892fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1901 let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1902 let authority = (
1903 first.host_str()?.to_string(),
1904 first.port_or_known_default()?,
1905 );
1906 for raw in &urls[1..] {
1907 let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1908 if (parsed.host_str()?, parsed.port_or_known_default()?)
1909 != (authority.0.as_str(), authority.1)
1910 {
1911 return None;
1912 }
1913 }
1914 pinned_public_agent_pooled(
1915 &first,
1916 allow_private,
1917 "object-store URL",
1918 AgentShape {
1919 idle_per_host: V2_UPLOAD_CONCURRENCY,
1920 ..AgentShape::default()
1921 },
1922 )
1923 .ok()
1924}
1925
1926fn object_store_transport_error(error: ureq::Transport) -> LinkError {
1932 LinkError::Transport {
1933 hub: "the object store".to_string(),
1934 message: format!("network error ({:?})", error.kind()),
1935 }
1936}
1937
1938fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1939 let http = presigned_agent(cfg, raw)?;
1940 let deadline = std::time::Instant::now()
1941 .checked_add(std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS))
1942 .ok_or_else(upload_deadline_error)?;
1943 let mut attempt = 0;
1944 let result = loop {
1945 let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
1949 if let Some(map) = headers.as_object() {
1950 for (name, value) in map {
1951 if let Some(value) = value.as_str() {
1952 req = req.set(name, value);
1953 }
1954 }
1955 }
1956 match req.send_bytes(bytes) {
1957 Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
1963 attempt += 1;
1964 }
1965 Err(ureq::Error::Status(status, _))
1966 if status != 412
1967 && is_retryable_upload_status(status)
1968 && wait_for_upload_retry(deadline, attempt) =>
1969 {
1970 attempt += 1;
1971 }
1972 result => break result,
1973 }
1974 };
1975 match result {
1976 Ok(resp) if (200..300).contains(&resp.status()) => {
1977 drain_presigned_response(resp);
1978 Ok(())
1979 }
1980 Ok(resp) => Err(presigned_upload_refusal(resp)),
1981 Err(error) => match error {
1982 ureq::Error::Status(412, _) => Ok(()),
1987 ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
1988 ureq::Error::Transport(err) => Err(object_store_transport_error(err)),
1989 },
1990 }
1991}
1992
1993fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
2002 let mut buf = Vec::new();
2003 response
2004 .into_reader()
2005 .take(limit)
2006 .read_to_end(&mut buf)
2007 .map_err(|error| LinkError::Transport {
2008 hub: peer.to_string(),
2009 message: error.to_string(),
2010 })?;
2011 Ok(buf)
2012}
2013
2014fn drain_presigned_response(response: ureq::Response) {
2019 let mut reader = response.into_reader().take(64 * 1024);
2020 let _ = std::io::copy(&mut reader, &mut std::io::sink());
2021}
2022
2023fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
2026 let status = response.status();
2027 let detail = response
2028 .into_string()
2029 .ok()
2030 .map(|body| body.chars().take(400).collect::<String>())
2031 .filter(|body| !body.trim().is_empty());
2032 LinkError::Http {
2033 what: "pack upload",
2034 status,
2035 message: match detail {
2036 Some(body) => format!(
2037 "object store rejected the upload: {}",
2038 body.replace('\n', " ")
2039 ),
2040 None => "object store rejected the upload".to_string(),
2041 },
2042 code: None,
2043 details: None,
2044 }
2045}
2046
2047fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
2048 max_bytes.checked_add(1)
2049}
2050
2051fn presigned_download_read_limit() -> u64 {
2052 one_past_bounded_limit(MAX_PACK_BYTES)
2053 .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
2054}
2055
2056fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
2057 let http = presigned_agent(cfg, raw)?;
2058 let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
2059 Ok(resp) => resp,
2060 Err(error) => match *error {
2061 ureq::Error::Status(_, resp) => {
2062 return Err(LinkError::Http {
2063 what: "pack download",
2064 status: resp.status(),
2065 message: "object store rejected the download".to_string(),
2066 code: None,
2067 details: None,
2068 });
2069 }
2070 ureq::Error::Transport(err) => {
2071 return Err(LinkError::Transport {
2072 hub: "the object store".to_string(),
2073 message: err.to_string(),
2074 });
2075 }
2076 },
2077 };
2078 if !(200..300).contains(&resp.status()) {
2079 return Err(LinkError::Http {
2080 what: "pack download",
2081 status: resp.status(),
2082 message: "object store rejected the download".to_string(),
2083 code: None,
2084 details: None,
2085 });
2086 }
2087 let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
2088 if bytes.len() as u64 > MAX_PACK_BYTES {
2089 return Err(LinkError::InvalidPack {
2090 message: "download exceeds the compressed-size limit".to_string(),
2091 });
2092 }
2093 Ok(bytes)
2094}
2095
2096fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
2100 if !(200..300).contains(&r.status) {
2101 let message = r
2102 .body
2103 .as_ref()
2104 .and_then(|b| b.get("error"))
2105 .and_then(Value::as_str)
2106 .unwrap_or("unknown error")
2107 .to_string();
2108 let code = r
2109 .body
2110 .as_ref()
2111 .and_then(|b| b.get("code"))
2112 .and_then(Value::as_str)
2113 .map(str::to_string);
2114 let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
2115 return Err(LinkError::Http {
2116 what,
2117 status: r.status,
2118 message,
2119 code,
2120 details,
2121 });
2122 }
2123 r.body.ok_or(LinkError::NotJson {
2124 what,
2125 status: r.status,
2126 })
2127}
2128
2129fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2138 match ip {
2139 std::net::IpAddr::V4(ip) => {
2140 let [a, b, c, _] = ip.octets();
2141 !(a == 0
2142 || a == 10
2143 || a == 127
2144 || (a == 100 && (64..=127).contains(&b))
2145 || (a == 169 && b == 254)
2146 || (a == 172 && (16..=31).contains(&b))
2147 || (a == 192 && b == 0 && c == 0)
2148 || (a == 192 && b == 0 && c == 2)
2149 || (a == 192 && b == 88 && c == 99)
2150 || (a == 192 && b == 168)
2151 || (a == 198 && (b == 18 || b == 19))
2152 || (a == 198 && b == 51 && c == 100)
2153 || (a == 203 && b == 0 && c == 113)
2154 || a >= 224)
2155 }
2156 std::net::IpAddr::V6(ip) => {
2157 let segments = ip.segments();
2158 (segments[0] & 0xe000) == 0x2000
2163 && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2164 && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2165 && segments[0] != 0x2002
2166 && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2167 }
2168 }
2169}
2170
2171#[derive(Clone)]
2172struct PinnedRegistryResolver {
2173 netloc: String,
2174 addresses: Vec<std::net::SocketAddr>,
2175}
2176
2177impl ureq::Resolver for PinnedRegistryResolver {
2178 fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2179 if requested == self.netloc {
2180 Ok(self.addresses.clone())
2181 } else {
2182 Err(std::io::Error::new(
2183 std::io::ErrorKind::PermissionDenied,
2184 "registry request attempted to resolve an unvalidated authority",
2185 ))
2186 }
2187 }
2188}
2189
2190fn pinned_public_agent(
2191 url: &url::Url,
2192 allow_private: bool,
2193 label: &str,
2194) -> LinkResult<ureq::Agent> {
2195 pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2196}
2197
2198struct AgentShape {
2203 idle_per_host: usize,
2204 overall: std::time::Duration,
2205}
2206
2207impl Default for AgentShape {
2208 fn default() -> Self {
2209 Self {
2210 idle_per_host: 1,
2211 overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2212 }
2213 }
2214}
2215
2216fn pinned_public_agent_pooled(
2217 url: &url::Url,
2218 allow_private: bool,
2219 label: &str,
2220 shape: AgentShape,
2221) -> LinkResult<ureq::Agent> {
2222 let host = url
2223 .host_str()
2224 .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2225 let port = url
2226 .port_or_known_default()
2227 .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2228 let addresses = resolve_addresses_with_deadline(
2229 host,
2230 port,
2231 std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2232 )
2233 .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2234 if addresses.is_empty() {
2235 return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2236 }
2237 if !allow_private
2238 && addresses
2239 .iter()
2240 .any(|address| !is_public_registry_ip(address.ip()))
2241 {
2242 return Err(invalid_feed(format!(
2243 "{label} resolves to a non-public address"
2244 )));
2245 }
2246 let netloc = if host.contains(':') {
2247 format!("[{host}]:{port}")
2248 } else {
2249 format!("{host}:{port}")
2250 };
2251 Ok(agent_builder_with_timeout(shape.overall)
2252 .max_idle_connections_per_host(shape.idle_per_host.max(1))
2253 .resolver(PinnedRegistryResolver { netloc, addresses })
2254 .build())
2255}
2256
2257fn resolve_addresses_with_deadline(
2262 host: &str,
2263 port: u16,
2264 timeout: std::time::Duration,
2265) -> std::io::Result<Vec<std::net::SocketAddr>> {
2266 use std::net::ToSocketAddrs as _;
2267
2268 let host = host.to_string();
2269 let (send, receive) = std::sync::mpsc::sync_channel(1);
2270 std::thread::Builder::new()
2271 .name("dbmd-dns".to_string())
2272 .spawn(move || {
2273 let result = (host.as_str(), port)
2274 .to_socket_addrs()
2275 .map(|addresses| addresses.collect());
2276 let _ = send.send(result);
2277 })
2278 .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2279 match receive.recv_timeout(timeout) {
2280 Ok(result) => result,
2281 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2282 std::io::ErrorKind::TimedOut,
2283 "resolution exceeded its deadline",
2284 )),
2285 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2286 "resolver stopped without returning a result",
2287 )),
2288 }
2289}
2290
2291fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2292 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2293 pinned_public_agent(url, allow_private, "registry home")
2294}
2295
2296fn get_json_absolute(url: &str) -> LinkResult<Value> {
2301 let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2302 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2303 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2304 || !parsed.username().is_empty()
2305 || parsed.password().is_some()
2306 || parsed.query().is_some()
2307 || parsed.fragment().is_some()
2308 {
2309 return Err(invalid_feed("unsafe registry home URL"));
2310 }
2311 let http = registry_agent(&parsed)?;
2312 let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2313 Ok(resp) => resp,
2314 Err(error) => match *error {
2315 ureq::Error::Status(status, resp) => {
2316 let _ = resp;
2317 return Err(LinkError::Http {
2318 what: "registry home fetch",
2319 status,
2320 message: "the home node rejected the card request".to_string(),
2321 code: None,
2322 details: None,
2323 });
2324 }
2325 ureq::Error::Transport(err) => {
2326 return Err(LinkError::Transport {
2327 hub: url.to_string(),
2328 message: err.to_string(),
2329 });
2330 }
2331 },
2332 };
2333 if !(200..300).contains(&resp.status()) {
2334 return Err(LinkError::Http {
2335 what: "registry home fetch",
2336 status: resp.status(),
2337 message: "the home node returned a redirect or error".to_string(),
2338 code: None,
2339 details: None,
2340 });
2341 }
2342 let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2343 if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2344 return Err(LinkError::ResponseTooLarge {
2345 limit_bytes: MAX_REGISTRY_CARD_BYTES,
2346 });
2347 }
2348 serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2349 message: "the home node returned invalid JSON".to_string(),
2350 })
2351}
2352
2353pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2360 require_safe_ref(handle)?;
2361 let trust_directory = open_trust_dir(cfg)?;
2365 let reg = request_capped(
2366 cfg,
2367 "GET",
2368 &format!("/api/hub/registry/{handle}"),
2369 None,
2370 Auth::None,
2371 MAX_REGISTRY_CARD_BYTES,
2372 )?;
2373 if reg.status == 404 {
2374 return Ok(None);
2375 }
2376 let body = ensure_ok(reg, "registry resolve")?;
2377 let home = body
2378 .get("home")
2379 .and_then(Value::as_str)
2380 .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2381 let brain = body
2382 .get("brain")
2383 .and_then(Value::as_str)
2384 .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2385 if !crate::ulid::is_ulid(brain) {
2386 return Err(invalid_feed(
2387 "registry entry brain is not a canonical lowercase ULID",
2388 ));
2389 }
2390 let want_fp = body
2391 .get("identity")
2392 .and_then(|i| i.get("fingerprint"))
2393 .and_then(Value::as_str)
2394 .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2395
2396 let home = home.trim_end_matches('/');
2397 let origin = normalized_origin(home)?;
2398 if origin != home {
2399 return Err(invalid_feed(
2400 "registry home must be an origin without a path, query, or fragment",
2401 ));
2402 }
2403 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2404 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2405 if let Some(binding) = &alias_binding {
2406 if binding
2407 .home
2408 .as_deref()
2409 .is_some_and(|pinned_home| pinned_home != home)
2410 {
2411 return Err(invalid_feed(
2412 "registry relocated a pinned handle to a different home",
2413 ));
2414 }
2415 }
2416 let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2417 if card.get("id").and_then(Value::as_str) != Some(brain) {
2418 return Err(invalid_feed(
2419 "the home node served a card for a different brain",
2420 ));
2421 }
2422 let identity: FeedIdentity = serde_json::from_value(
2423 card.get("identity")
2424 .cloned()
2425 .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2426 )
2427 .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2428 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2429 let got_fp = card
2430 .get("identity")
2431 .and_then(|i| i.get("fingerprint"))
2432 .and_then(Value::as_str)
2433 .unwrap_or_default();
2434 if got_fp != want_fp {
2435 return Err(invalid_feed(
2436 "the home node served an identity that does not match the registry — refusing",
2437 ));
2438 }
2439 let current = format!("ed25519:{}", identity.fingerprint);
2440 let advertised_seq = card
2441 .get("headSeq")
2442 .and_then(Value::as_u64)
2443 .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2444 let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2445 if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2446 || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2447 {
2448 return Err(invalid_feed(
2449 "the home node served an invalid feed head boundary",
2450 ));
2451 }
2452 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2456 let registry_alias = AliasBinding {
2457 v: 1,
2458 origin: normalized_origin(&cfg.hub)?,
2459 requested: handle.to_string(),
2460 brain: brain.to_string(),
2461 home: Some(home.to_string()),
2462 };
2463 save_canonical_pin_and_alias(
2464 cfg,
2465 &trust_directory,
2466 handle,
2467 brain,
2468 TrustState {
2469 v: 2,
2470 origin: normalized_origin(&cfg.hub)?,
2471 requested: brain.to_string(),
2472 brain: brain.to_string(),
2473 home: None,
2474 anchor,
2475 current,
2476 head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2477 feed_hash: pinned
2478 .as_ref()
2479 .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2480 rotations: identity.rotations.clone(),
2481 hub_signer: None,
2482 protocol_profile: None,
2483 },
2484 Some(®istry_alias),
2485 )?;
2486 let mut out = card;
2487 if let Value::Object(map) = &mut out {
2488 map.insert("home".to_string(), Value::String(home.to_string()));
2489 map.insert(
2490 "resolvedVia".to_string(),
2491 Value::String("registry".to_string()),
2492 );
2493 }
2494 Ok(Some(out))
2495}
2496
2497pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2498 require_safe_ref(&addr.brain)?;
2502 if let Some(target) = &addr.target {
2503 let (given, ok) = match target {
2504 AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2505 AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2506 };
2507 if !ok {
2508 return Err(LinkError::BadAddress {
2509 given: given.clone(),
2510 reason: BAD_TARGET_REASON.to_string(),
2511 });
2512 }
2513 }
2514
2515 if let Some(target) = &addr.target {
2521 if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2522 let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2523 what: "resolve",
2524 status: 404,
2525 message: "record not found".to_string(),
2526 code: Some("NOT_FOUND".to_string()),
2527 details: None,
2528 })?;
2529 let (path, file) = match target {
2530 AddressTarget::Path(path) => {
2531 let file =
2532 v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2533 LinkError::Http {
2534 what: "resolve",
2535 status: 404,
2536 message: "record not found".to_string(),
2537 code: Some("NOT_FOUND".to_string()),
2538 details: None,
2539 }
2540 })?;
2541 (path.clone(), file)
2542 }
2543 AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2544 };
2545 let mut downloaded =
2546 download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2547 let (_, bytes) = downloaded
2548 .pop()
2549 .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2550 let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2551 accept_v2_head(cfg, &head)?;
2552 return Ok(resolved);
2553 }
2554 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2555 if !remote.head.verified {
2556 return Err(invalid_feed(
2557 "a path-scoped feed cannot prove a record against the full signed snapshot",
2558 ));
2559 }
2560 if remote.head.seq == 0 {
2561 return Err(LinkError::Http {
2562 what: "resolve",
2563 status: 404,
2564 message: "record not found".to_string(),
2565 code: Some("NOT_FOUND".to_string()),
2566 details: None,
2567 });
2568 }
2569 let brain = remote.head.brain.clone();
2570 let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2571 return resolve_from_verified_pack(&brain, target, pack);
2572 }
2573
2574 let path = format!("/api/hub/brains/{}", addr.brain);
2575 let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2580 if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2581 if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2582 return Ok(card);
2583 }
2584 }
2585 let mut resolved = ensure_ok(direct, "resolve")?;
2586 if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2587 let v2 = v2_verified_head(cfg, &addr.brain)?
2588 .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2589 if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2590 return Err(invalid_feed(
2591 "resolve card is not bound to the verified v2 brain",
2592 ));
2593 }
2594 let card_identity: FeedIdentity = serde_json::from_value(
2595 resolved
2596 .get("identity")
2597 .cloned()
2598 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2599 )
2600 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2601 if card_identity != v2_identity(&v2.identity) {
2602 return Err(invalid_feed(
2603 "resolve card identity differs from the verified v2 identity",
2604 ));
2605 }
2606 accept_v2_head(cfg, &v2)?;
2607 if let Value::Object(card) = &mut resolved {
2608 card.insert(
2609 "headSeq".to_string(),
2610 json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2611 );
2612 card.insert(
2613 "feedHash".to_string(),
2614 v2.pointer
2615 .as_ref()
2616 .map(|pointer| Value::String(pointer.feed_hash.clone()))
2617 .unwrap_or(Value::Null),
2618 );
2619 card.insert(
2620 "storageProfile".to_string(),
2621 Value::String("v2".to_string()),
2622 );
2623 if let Some(pointer) = &v2.pointer {
2624 card.insert(
2625 "updatedAt".to_string(),
2626 Value::String(pointer.signed_at.clone()),
2627 );
2628 }
2629 }
2630 return Ok(resolved);
2631 }
2632 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2636 if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2637 || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2638 || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2639 {
2640 return Err(invalid_feed(
2641 "resolve card is not bound to the exact verified feed checkpoint",
2642 ));
2643 }
2644 let card_identity: FeedIdentity = serde_json::from_value(
2645 resolved
2646 .get("identity")
2647 .cloned()
2648 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2649 )
2650 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2651 if remote.identity.as_ref() != Some(&card_identity) {
2652 return Err(invalid_feed(
2653 "resolve card identity differs from the verified feed identity",
2654 ));
2655 }
2656 Ok(resolved)
2657}
2658
2659fn resolve_from_verified_pack(
2664 brain: &str,
2665 target: &AddressTarget,
2666 pack: Vec<u8>,
2667) -> LinkResult<Value> {
2668 let entries = parse_store_pack(pack)?;
2669 let mut matched: Option<(String, Vec<u8>)> = None;
2670
2671 for (path, bytes) in entries {
2672 let is_candidate = match target {
2673 AddressTarget::Path(want) => &path == want,
2674 AddressTarget::Id(_) => {
2675 path.ends_with(".md")
2676 && (path.starts_with("records/") || path.starts_with("sources/"))
2677 }
2678 };
2679 if !is_candidate {
2680 continue;
2681 }
2682 let text = std::str::from_utf8(&bytes)
2683 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2684 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2685 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2686 if let AddressTarget::Id(want) = target {
2687 let frontmatter =
2688 crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2689 .map_err(|_| {
2690 invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2691 })?;
2692 if frontmatter.id.as_deref() != Some(want) {
2693 continue;
2694 }
2695 }
2696 if matched.is_some() {
2697 return Err(invalid_feed(
2698 "signed snapshot contains more than one record for the requested target",
2699 ));
2700 }
2701 matched = Some((path, bytes));
2702 }
2703
2704 let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2705 what: "resolve",
2706 status: 404,
2707 message: "record not found".to_string(),
2708 code: Some("NOT_FOUND".to_string()),
2709 details: None,
2710 })?;
2711 resolve_from_verified_record_bytes(brain, target, path, bytes)
2712}
2713
2714fn resolve_from_verified_record_bytes(
2715 brain: &str,
2716 target: &AddressTarget,
2717 path: String,
2718 bytes: Vec<u8>,
2719) -> LinkResult<Value> {
2720 match target {
2721 AddressTarget::Path(expected) if expected != &path => {
2722 return Err(invalid_feed(
2723 "verified record path differs from the requested path",
2724 ));
2725 }
2726 AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2727 return Err(invalid_feed(
2728 "verified id resolved outside records or sources",
2729 ));
2730 }
2731 _ => {}
2732 }
2733 let text = std::str::from_utf8(&bytes)
2734 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2735 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2736 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2737 let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2738 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2739 let Value::Object(fields) = frontmatter else {
2740 return Err(invalid_feed(format!(
2741 "signed snapshot record `{path}` frontmatter is not a mapping"
2742 )));
2743 };
2744 if let AddressTarget::Id(expected) = target {
2745 if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2746 return Err(invalid_feed(
2747 "verified record id differs from the requested id",
2748 ));
2749 }
2750 }
2751 let mut document = serde_json::Map::new();
2752 document.insert("path".to_string(), Value::String(path));
2753 for (key, value) in fields {
2754 document.insert(key, value);
2755 }
2756 document.insert("body".to_string(), Value::String(parsed.body));
2757 document.insert(
2758 "contentSha".to_string(),
2759 Value::String(content_sha256(&bytes)),
2760 );
2761 Ok(json!({
2762 "brain": brain,
2763 "document": Value::Object(document),
2764 }))
2765}
2766
2767#[derive(Debug, Clone, serde::Serialize)]
2773pub struct PullReport {
2774 pub brain: String,
2776 pub slug: String,
2778 #[serde(rename = "headSeq")]
2780 pub head_seq: u64,
2781 pub files: usize,
2783 pub dest: String,
2785 #[serde(rename = "extraLocal")]
2788 pub extra_local: Vec<String>,
2789 #[serde(rename = "syncStatus")]
2791 pub sync_status: String,
2792}
2793
2794struct V2PulledSnapshot {
2795 report: PullReport,
2796 head: V2VerifiedHead,
2797 files: std::collections::BTreeMap<String, V2BaselineFile>,
2798 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2799 local: V2LocalView,
2800 local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2801}
2802
2803fn download_verified_snapshot_pack(
2804 cfg: &HubConfig,
2805 brain: &str,
2806 remote: &VerifiedRemote,
2807) -> LinkResult<Vec<u8>> {
2808 let feed_hash = remote
2809 .head
2810 .feed_hash
2811 .as_deref()
2812 .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2813 let signed_head = remote
2814 .head_entry
2815 .as_ref()
2816 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2817 let expected = &signed_head.entry.pack_sha256;
2818 if !is_sha256(expected) {
2819 return Err(invalid_feed(
2820 "signed head carries an invalid snapshot pack digest",
2821 ));
2822 }
2823 let path = format!(
2824 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2825 remote.head.seq
2826 );
2827 let body = ensure_ok(
2828 request(cfg, "GET", &path, None, Auth::Required)?,
2829 "sync pull",
2830 )?;
2831 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2832 || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2833 || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2834 || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2835 {
2836 return Err(invalid_feed(
2837 "export response is not bound to the exact verified snapshot",
2838 ));
2839 }
2840 let url = body
2841 .get("url")
2842 .and_then(Value::as_str)
2843 .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2844 let bytes = get_presigned(cfg, url)?;
2845 if content_sha256(&bytes) != *expected {
2846 return Err(LinkError::InvalidPack {
2847 message: "downloaded pack does not match the signed snapshot digest".to_string(),
2848 });
2849 }
2850 let entries = parse_store_pack(bytes.clone())?;
2851 if signed_head.entry.kind == "push" {
2852 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2853 }
2854 Ok(bytes)
2855}
2856
2857#[derive(Debug, Clone, Deserialize, Serialize)]
2858struct V2PointerBody {
2859 v: u8,
2860 brain: String,
2861 seq: u64,
2862 commit_hash: String,
2863 feed_hash: String,
2864 content_root: Option<String>,
2865 asset_root: Option<String>,
2866 materializer: String,
2867 signer_epoch: u64,
2868 control_revision: String,
2869 backup_preparation: String,
2870 prior_pointer_hash: Option<String>,
2871 signed_at: String,
2872}
2873
2874#[derive(Debug, Clone, Deserialize)]
2875struct V2SignedPointer {
2876 pointer: V2PointerBody,
2877 hub_public_key: String,
2878 hub_fingerprint: String,
2879 sig: String,
2880}
2881
2882#[derive(Debug, Clone, Deserialize)]
2883struct V2HeadIdentity {
2884 #[serde(default)]
2885 custody: String,
2886 fingerprint: String,
2887 public_key_spki: String,
2888 #[serde(default)]
2889 previous: Vec<V2PreviousIdentity>,
2890 #[serde(default)]
2891 rotations: Vec<String>,
2892}
2893
2894#[derive(Debug, Clone, Deserialize)]
2895struct V2PreviousIdentity {
2896 fingerprint: String,
2897 public_key_spki: String,
2898}
2899
2900#[derive(Debug, Deserialize)]
2901struct V2HeadResponse {
2902 v: u8,
2903 brain_id: String,
2904 profile: String,
2905 view: Option<V2HeadView>,
2906 pointer: Option<V2SignedPointer>,
2907 identity: Option<V2HeadIdentity>,
2908}
2909
2910#[derive(Debug, Clone, Deserialize)]
2911struct V2HeadView {
2912 kind: String,
2913 #[serde(default)]
2914 id: Option<String>,
2915 control_revision: String,
2916}
2917
2918#[derive(Debug, Clone)]
2919struct V2VerifiedHead {
2920 requested: String,
2921 brain_id: String,
2922 view_kind: String,
2923 view_revision: String,
2925 control_revision: String,
2927 identity: V2HeadIdentity,
2928 pointer: Option<V2PointerBody>,
2929 trust: TrustState,
2930 alias: Option<AliasBinding>,
2931}
2932
2933fn verify_v2_spki_signature(
2934 public_key: &str,
2935 message: &[u8],
2936 signature: &str,
2937) -> LinkResult<Vec<u8>> {
2938 let der = URL_SAFE_NO_PAD
2939 .decode(public_key)
2940 .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2941 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2942 return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2943 }
2944 let sig = URL_SAFE_NO_PAD
2945 .decode(signature)
2946 .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2947 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2948 .verify(message, &sig)
2949 .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2950 Ok(der)
2951}
2952
2953fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2954 if pointer.pointer.v != 2
2955 || pointer.pointer.brain != expected_brain
2956 || pointer.pointer.seq == 0
2957 || !is_sha256(&pointer.pointer.commit_hash)
2958 || !is_sha256(&pointer.pointer.feed_hash)
2959 || pointer
2960 .pointer
2961 .content_root
2962 .as_deref()
2963 .is_some_and(|hash| !is_sha256(hash))
2964 || !is_sha256(&pointer.pointer.backup_preparation)
2965 {
2966 return Err(invalid_feed("v2 pointer fields are invalid"));
2967 }
2968 let value = serde_json::to_value(&pointer.pointer)
2969 .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2970 let message = crate::linkmd_v2::canonical_bytes(&value)
2971 .map_err(|error| invalid_feed(error.to_string()))?;
2972 let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2973 let fingerprint = format!("{:x}", Sha256::digest(&der));
2974 if fingerprint != pointer.hub_fingerprint {
2975 return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2976 }
2977 Ok(format!(
2978 "{}:{}",
2979 pointer.hub_fingerprint, pointer.hub_public_key
2980 ))
2981}
2982
2983fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2984 FeedIdentity {
2985 fingerprint: identity.fingerprint.clone(),
2986 public_key_spki: identity.public_key_spki.clone(),
2987 previous: identity
2988 .previous
2989 .iter()
2990 .map(|previous| PreviousIdentity {
2991 fingerprint: previous.fingerprint.clone(),
2992 public_key_spki: previous.public_key_spki.clone(),
2993 })
2994 .collect(),
2995 rotations: identity.rotations.clone(),
2996 }
2997}
2998
2999fn verified_v2_commit_object(
3000 raw: &[u8],
3001 identity: &V2HeadIdentity,
3002) -> LinkResult<serde_json::Map<String, Value>> {
3003 let mut value: Value =
3004 serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
3005 let canonical = crate::linkmd_v2::canonical_bytes(&value)
3006 .map_err(|error| invalid_feed(error.to_string()))?;
3007 if canonical != raw {
3008 return Err(invalid_feed("v2 commit is not canonical JSON"));
3009 }
3010 let object = value
3011 .as_object_mut()
3012 .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
3013 let sig = object
3014 .remove("sig")
3015 .and_then(|value| value.as_str().map(str::to_string))
3016 .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
3017 const FIELDS: [&str; 18] = [
3018 "actor_ref",
3019 "asset_root",
3020 "brain",
3021 "changes_sha256",
3022 "control_revision",
3023 "materializer",
3024 "op",
3025 "parent_asset_root",
3026 "parent_commit",
3027 "parent_root",
3028 "prev_entry_hash",
3029 "public_key",
3030 "seq",
3031 "signer_epoch",
3032 "state_root",
3033 "ts",
3034 "v",
3035 "v1_bridge",
3036 ];
3037 if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
3038 return Err(invalid_feed("v2 commit has a non-normative field set"));
3039 }
3040 let seq = object
3041 .get("seq")
3042 .and_then(Value::as_u64)
3043 .filter(|seq| *seq > 0)
3044 .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
3045 let signer_epoch = object
3046 .get("signer_epoch")
3047 .and_then(Value::as_u64)
3048 .filter(|epoch| *epoch > 0)
3049 .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
3050 let hash_or_null = |field: &str| {
3051 object
3052 .get(field)
3053 .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
3054 };
3055 if object.get("v").and_then(Value::as_u64) != Some(2)
3056 || object.get("op").and_then(Value::as_str) != Some("changeset")
3057 || !object
3058 .get("changes_sha256")
3059 .and_then(Value::as_str)
3060 .is_some_and(is_sha256)
3061 || !object
3062 .get("actor_ref")
3063 .and_then(Value::as_str)
3064 .is_some_and(is_sha256)
3065 || !object
3066 .get("control_revision")
3067 .and_then(Value::as_str)
3068 .is_some_and(is_sha256)
3069 || !object
3070 .get("state_root")
3071 .and_then(Value::as_str)
3072 .is_some_and(is_sha256)
3073 || !hash_or_null("parent_commit")
3074 || !hash_or_null("parent_root")
3075 || !hash_or_null("parent_asset_root")
3076 || !hash_or_null("asset_root")
3077 || !hash_or_null("prev_entry_hash")
3078 || !object
3079 .get("materializer")
3080 .and_then(Value::as_str)
3081 .is_some_and(|value| !value.is_empty() && value.len() <= 128)
3082 || !object
3083 .get("ts")
3084 .and_then(Value::as_str)
3085 .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
3086 {
3087 return Err(invalid_feed("v2 commit fields are invalid"));
3088 }
3089 if (seq == 1
3090 && [
3091 "parent_commit",
3092 "parent_root",
3093 "parent_asset_root",
3094 "prev_entry_hash",
3095 ]
3096 .iter()
3097 .any(|field| !object.get(*field).is_some_and(Value::is_null)))
3098 || (seq > 1
3099 && ["parent_commit", "parent_root", "prev_entry_hash"]
3100 .iter()
3101 .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
3102 {
3103 return Err(invalid_feed("v2 commit parent shape is invalid"));
3104 }
3105 match object.get("v1_bridge") {
3106 Some(Value::Null) => {}
3107 Some(Value::Object(bridge))
3108 if seq == 1
3109 && bridge.len() == 3
3110 && bridge
3111 .get("head_seq")
3112 .and_then(Value::as_u64)
3113 .is_some_and(|v| v > 0)
3114 && bridge
3115 .get("feed_hash")
3116 .and_then(Value::as_str)
3117 .is_some_and(is_sha256)
3118 && bridge
3119 .get("pack_sha256")
3120 .and_then(Value::as_str)
3121 .is_some_and(is_sha256) => {}
3122 _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
3123 }
3124 let public_key = object
3125 .get("public_key")
3126 .and_then(Value::as_str)
3127 .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3128 let der = URL_SAFE_NO_PAD
3129 .decode(public_key)
3130 .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3131 let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3132 if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3133 return Err(invalid_feed("v2 commit brain identity mismatch"));
3134 }
3135 verify_identity_chain(&v2_identity(identity), None)?;
3137 let mut chain: Vec<(&str, &str)> = identity
3140 .previous
3141 .iter()
3142 .rev()
3143 .map(|previous| {
3144 (
3145 previous.fingerprint.as_str(),
3146 previous.public_key_spki.as_str(),
3147 )
3148 })
3149 .collect();
3150 chain.push((&identity.fingerprint, &identity.public_key_spki));
3151 let signer_index = chain.iter().position(|(fingerprint, spki)| {
3152 *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3153 });
3154 let Some(signer_index) = signer_index else {
3155 return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3156 };
3157 if signer_epoch != signer_index as u64 + 1 {
3158 return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3159 }
3160 let lower_boundary = if signer_index == 0 {
3161 None
3162 } else {
3163 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3164 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3165 Some(prior.prior_head_seq)
3166 };
3167 let upper_boundary = if signer_index == identity.rotations.len() {
3168 None
3169 } else {
3170 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3171 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3172 Some(next.prior_head_seq)
3173 };
3174 if lower_boundary.is_some_and(|boundary| seq <= boundary)
3175 || upper_boundary.is_some_and(|boundary| seq > boundary)
3176 {
3177 return Err(invalid_feed(
3178 "v2 commit signer is outside its authenticated rotation epoch",
3179 ));
3180 }
3181 let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3182 .map_err(|error| invalid_feed(error.to_string()))?;
3183 verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3184 Ok(object.clone())
3185}
3186
3187#[derive(Debug, Deserialize)]
3188struct V2FeedWireEntry {
3189 seq: u64,
3190 commit_hash: String,
3191 feed_hash: String,
3192 bytes_base64: String,
3193}
3194
3195#[derive(Debug, Deserialize)]
3196struct V2FeedPage {
3197 v: u8,
3198 head_seq: u64,
3199 head_commit_hash: String,
3200 head_feed_hash: String,
3201 entries: Vec<V2FeedWireEntry>,
3202 next_after: u64,
3203 complete: bool,
3204}
3205
3206fn replay_v2_feed(
3207 cfg: &HubConfig,
3208 brain: &str,
3209 pointer: &V2PointerBody,
3210 identity: &V2HeadIdentity,
3211 start_after: u64,
3212 start_feed: Option<String>,
3213) -> LinkResult<()> {
3214 let mut after = start_after;
3215 let mut prior_feed = start_feed;
3216 let mut final_object = None;
3217 let mut replayed_entries = 0_u64;
3218 let mut replayed_bytes = 0_u64;
3219 while after < pointer.seq {
3220 let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3221 let value = ensure_ok(
3222 request_capped(
3223 cfg,
3224 "GET",
3225 &path,
3226 None,
3227 Auth::Required,
3228 MAX_FEED_REPLAY_BYTES,
3229 )?,
3230 "v2 feed replay",
3231 )?;
3232 let page: V2FeedPage = serde_json::from_value(value)
3233 .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3234 if page.v != 2
3235 || page.head_seq != pointer.seq
3236 || page.head_commit_hash != pointer.commit_hash
3237 || page.head_feed_hash != pointer.feed_hash
3238 || page.entries.is_empty()
3239 || page.entries.len() > FEED_PAGE_LIMIT
3240 {
3241 return Err(invalid_feed("v2 feed page differs from the signed head"));
3242 }
3243 for entry in page.entries {
3244 if entry.seq != after + 1
3245 || !is_sha256(&entry.commit_hash)
3246 || !is_sha256(&entry.feed_hash)
3247 {
3248 return Err(invalid_feed("v2 feed sequence is not contiguous"));
3249 }
3250 let raw = base64::engine::general_purpose::STANDARD
3251 .decode(&entry.bytes_base64)
3252 .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3253 replayed_entries = replayed_entries
3254 .checked_add(1)
3255 .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3256 replayed_bytes = replayed_bytes
3257 .checked_add(raw.len() as u64)
3258 .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3259 if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3260 {
3261 return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3262 }
3263 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3264 .map_err(|error| invalid_feed(error.to_string()))?
3265 != entry.commit_hash
3266 || content_sha256(&raw) != entry.feed_hash
3267 {
3268 return Err(invalid_feed("v2 feed entry address mismatch"));
3269 }
3270 let object = verified_v2_commit_object(&raw, identity)?;
3271 if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3272 || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3273 {
3274 return Err(invalid_feed(
3275 "v2 feed entry does not extend its predecessor",
3276 ));
3277 }
3278 after = entry.seq;
3279 prior_feed = Some(entry.feed_hash);
3280 final_object = Some((entry.commit_hash, object));
3281 }
3282 if page.next_after != after || (page.complete != (after == pointer.seq)) {
3283 return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3284 }
3285 }
3286 let (final_hash, object) =
3287 final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3288 if final_hash != pointer.commit_hash
3289 || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3290 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3291 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3292 || object.get("control_revision").and_then(Value::as_str)
3293 != Some(pointer.control_revision.as_str())
3294 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3295 {
3296 return Err(invalid_feed(
3297 "v2 replay did not converge on the signed pointer",
3298 ));
3299 }
3300 Ok(())
3301}
3302
3303fn verify_v1_to_v2_bridge(
3304 cfg: &HubConfig,
3305 brain: &str,
3306 pointer: &V2PointerBody,
3307 identity: &V2HeadIdentity,
3308 checkpoint: &TrustState,
3309) -> LinkResult<()> {
3310 let value = ensure_ok(
3311 request_capped(
3312 cfg,
3313 "GET",
3314 &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3315 None,
3316 Auth::Required,
3317 MAX_FEED_RESPONSE_BYTES,
3318 )?,
3319 "v2 genesis bridge",
3320 )?;
3321 let page: V2FeedPage = serde_json::from_value(value)
3322 .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3323 if page.v != 2
3324 || page.head_seq != pointer.seq
3325 || page.head_commit_hash != pointer.commit_hash
3326 || page.head_feed_hash != pointer.feed_hash
3327 || page.entries.len() != 1
3328 || page.entries[0].seq != 1
3329 || !is_sha256(&page.entries[0].commit_hash)
3330 || !is_sha256(&page.entries[0].feed_hash)
3331 {
3332 return Err(invalid_feed(
3333 "v2 genesis bridge page differs from the signed head",
3334 ));
3335 }
3336 let first = &page.entries[0];
3337 let raw = STANDARD
3338 .decode(&first.bytes_base64)
3339 .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3340 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3341 .map_err(|error| invalid_feed(error.to_string()))?
3342 != first.commit_hash
3343 || content_sha256(&raw) != first.feed_hash
3344 {
3345 return Err(invalid_feed("v2 genesis bridge address mismatch"));
3346 }
3347 let object = verified_v2_commit_object(&raw, identity)?;
3348 if checkpoint.head_seq == 0 {
3349 if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3350 return Err(invalid_feed(
3351 "empty v1 checkpoint did not transition through an empty v2 genesis",
3352 ));
3353 }
3354 return Ok(());
3355 }
3356 let bridge = object
3357 .get("v1_bridge")
3358 .and_then(Value::as_object)
3359 .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3360 let checkpoint_feed = checkpoint
3361 .feed_hash
3362 .as_deref()
3363 .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3364 if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3365 || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3366 {
3367 return Err(invalid_feed(
3368 "v2 genesis bridge differs from the pinned v1 checkpoint",
3369 ));
3370 }
3371 let legacy_raw = ensure_raw_ok(
3372 request_raw(
3373 cfg,
3374 "GET",
3375 &format!(
3376 "/api/hub/brains/{brain}/feed?after={}&limit=1",
3377 checkpoint.head_seq - 1
3378 ),
3379 None,
3380 Auth::Required,
3381 MAX_FEED_RESPONSE_BYTES,
3382 )?,
3383 "v1 bridge boundary",
3384 )?;
3385 let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3386 .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3387 let legacy_identity = legacy
3388 .identity
3389 .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3390 let item = legacy
3391 .entries
3392 .first()
3393 .filter(|_| legacy.entries.len() == 1)
3394 .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3395 if legacy.scope_limited
3396 || legacy.head_seq != checkpoint.head_seq
3397 || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3398 || item.entry.seq != checkpoint.head_seq
3399 || item.hash != checkpoint_feed
3400 || legacy_identity != v2_identity(identity)
3401 || bridge.get("pack_sha256").and_then(Value::as_str)
3402 != Some(item.entry.pack_sha256.as_str())
3403 {
3404 return Err(invalid_feed(
3405 "v1 bridge boundary differs from its signed legacy head",
3406 ));
3407 }
3408 let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3409 if anchor != checkpoint.anchor {
3410 return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3411 }
3412 verify_feed_item(item, &legacy_identity)?;
3413 verify_rotation_feed_boundaries(
3414 &legacy_identity,
3415 Some(checkpoint),
3416 std::slice::from_ref(item),
3417 checkpoint.head_seq,
3418 )?;
3419 Ok(())
3420}
3421
3422fn verify_v2_commit(
3423 cfg: &HubConfig,
3424 brain: &str,
3425 pointer: &V2PointerBody,
3426 identity: &V2HeadIdentity,
3427 pinned: Option<&TrustState>,
3428) -> LinkResult<()> {
3429 let path = format!(
3430 "/api/hub/brains/{brain}/v2/commit?commit={}",
3431 pointer.commit_hash
3432 );
3433 let raw = ensure_raw_ok(
3434 request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3435 "v2 commit",
3436 )?;
3437 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3438 .map_err(|error| invalid_feed(error.to_string()))?
3439 != pointer.commit_hash
3440 || content_sha256(&raw) != pointer.feed_hash
3441 {
3442 return Err(invalid_feed("v2 commit address differs from the pointer"));
3443 }
3444 let object = verified_v2_commit_object(&raw, identity)?;
3445 if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3446 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3447 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3448 || object.get("control_revision").and_then(Value::as_str)
3449 != Some(pointer.control_revision.as_str())
3450 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3451 {
3452 return Err(invalid_feed("v2 commit fields differ from the pointer"));
3453 }
3454 if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3455 if pointer.seq == checkpoint.head_seq + 1
3456 && object.get("prev_entry_hash").and_then(Value::as_str)
3457 != checkpoint.feed_hash.as_deref()
3458 {
3459 return Err(invalid_feed(
3460 "v2 commit does not extend the pinned feed hash",
3461 ));
3462 }
3463 if pointer.seq > checkpoint.head_seq + 1 {
3464 return replay_v2_feed(
3465 cfg,
3466 brain,
3467 pointer,
3468 identity,
3469 checkpoint.head_seq,
3470 checkpoint.feed_hash.clone(),
3471 );
3472 }
3473 } else {
3474 if let Some(checkpoint) = pinned {
3475 verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3476 }
3477 if pointer.seq > 1 {
3478 return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3479 }
3480 }
3481 Ok(())
3482}
3483
3484fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3485 require_hardened_filesystem("verified link.md v2 state")?;
3486 require_safe_ref(brain)?;
3487 let trust_directory = open_trust_dir(cfg)?;
3491 let path = format!("/api/hub/brains/{brain}/v2/head");
3492 let response = request(cfg, "GET", &path, None, Auth::Required)?;
3493 if response.status == 404 {
3494 if has_accepted_v2_ref(cfg, brain)? {
3495 return Err(LinkError::BrainUnavailable);
3496 }
3497 return Ok(None);
3498 }
3499 let body = ensure_ok(response, "v2 head")?;
3500 let head: V2HeadResponse = serde_json::from_value(body)
3501 .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3502 if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3503 return Err(invalid_feed("v2 head has no canonical brain id"));
3504 }
3505 if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3506 return Err(invalid_feed("v2 head resolved a different brain id"));
3507 }
3508 if head.profile == "v1" {
3509 return Ok(None);
3510 }
3511 if head.profile != "v2" && head.profile != "v2-empty" {
3512 return Err(invalid_feed("v2 head advertised an unknown profile"));
3513 }
3514 let view = head
3515 .view
3516 .as_ref()
3517 .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3518 if !matches!(view.kind.as_str(), "full" | "scoped")
3519 || !is_sha256(&view.control_revision)
3520 || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3521 {
3522 return Err(invalid_feed("v2 head has an invalid permission view"));
3523 }
3524 let view_kind = view.kind.clone();
3525 let view_revision = view
3528 .id
3529 .clone()
3530 .unwrap_or_else(|| view.control_revision.clone());
3531 let control_revision = view.control_revision.clone();
3532 let identity = head
3533 .identity
3534 .as_ref()
3535 .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3536 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3537 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3538 let feed_identity = v2_identity(identity);
3539 let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3540 let (seq, feed_hash, hub_signer) = match &head.pointer {
3541 None => {
3542 if head.profile != "v2-empty" {
3543 return Err(invalid_feed("initialized v2 head has no pointer"));
3544 }
3545 (
3546 0,
3547 None,
3548 pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3549 )
3550 }
3551 Some(signed) => {
3552 let signer = verify_v2_pointer(signed, &head.brain_id)?;
3553 if pinned
3554 .as_ref()
3555 .and_then(|state| state.hub_signer.as_ref())
3556 .is_some_and(|known| known != &signer)
3557 {
3558 return Err(invalid_feed(
3559 "v2 hub pointer signer changed without a trust transition",
3560 ));
3561 }
3562 if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3563 if signed.pointer.seq < checkpoint.head_seq
3564 || (signed.pointer.seq == checkpoint.head_seq
3565 && checkpoint.feed_hash.as_deref()
3566 != Some(signed.pointer.feed_hash.as_str()))
3567 {
3568 return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3569 }
3570 }
3571 verify_v2_commit(
3572 cfg,
3573 &head.brain_id,
3574 &signed.pointer,
3575 identity,
3576 pinned.as_ref(),
3577 )?;
3578 (
3579 signed.pointer.seq,
3580 Some(signed.pointer.feed_hash.clone()),
3581 Some(signer),
3582 )
3583 }
3584 };
3585 let trust = TrustState {
3586 v: 2,
3587 origin: normalized_origin(&cfg.hub)?,
3588 requested: head.brain_id.clone(),
3589 brain: head.brain_id.clone(),
3590 home: None,
3591 anchor,
3592 current: format!("ed25519:{}", identity.fingerprint),
3593 head_seq: seq,
3594 feed_hash,
3595 rotations: identity.rotations.clone(),
3596 hub_signer,
3597 protocol_profile: Some("link-v2".to_string()),
3598 };
3599 Ok(Some(V2VerifiedHead {
3600 requested: brain.to_string(),
3601 brain_id: head.brain_id,
3602 view_kind,
3603 view_revision,
3604 control_revision,
3605 identity: identity.clone(),
3606 pointer: head.pointer.map(|signed| signed.pointer),
3607 trust,
3608 alias: alias_binding,
3609 }))
3610}
3611
3612fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3613 let directory = open_trust_dir(cfg)?;
3614 let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3615 let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3616 if let Some(current) = current {
3617 let common_invalid = head.trust.anchor != current.anchor
3618 || !head.trust.rotations.starts_with(¤t.rotations);
3619 let profile_invalid = if accepted_as_v2(¤t) {
3620 head.trust.head_seq < current.head_seq
3621 || (head.trust.head_seq == current.head_seq
3622 && head.trust.feed_hash != current.feed_hash)
3623 || current
3624 .hub_signer
3625 .as_ref()
3626 .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3627 } else {
3628 head.trust.protocol_profile.as_deref() != Some("link-v2")
3629 || head.trust.hub_signer.is_none()
3630 };
3631 if common_invalid || profile_invalid {
3632 return Err(invalid_feed(
3633 "v2 head cannot advance the currently accepted trust checkpoint",
3634 ));
3635 }
3636 }
3637 save_canonical_pin_and_alias(
3638 cfg,
3639 &directory,
3640 &head.requested,
3641 &head.brain_id,
3642 head.trust.clone(),
3643 alias.as_ref().or(head.alias.as_ref()),
3644 )
3645}
3646
3647#[derive(Debug, Clone, Deserialize, Serialize)]
3648struct V2BaselineFile {
3649 sha256: String,
3650 bytes: u64,
3651 #[serde(skip)]
3652 proof: Option<Vec<V2ProofStep>>,
3653}
3654
3655#[derive(Debug, Clone, Deserialize, Serialize)]
3656struct V2SyncBaseline {
3657 v: u8,
3658 origin: String,
3659 brain: String,
3660 #[serde(default)]
3661 checkout_id: Option<String>,
3662 #[serde(default)]
3663 head_seq: Option<u64>,
3664 commit_hash: Option<String>,
3665 content_root: Option<String>,
3666 #[serde(default)]
3667 asset_root: Option<String>,
3668 #[serde(default)]
3669 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3670 #[serde(default)]
3671 view_kind: Option<String>,
3672 #[serde(default)]
3673 view_revision: Option<String>,
3674 #[serde(default)]
3678 control_revision: Option<String>,
3679 #[serde(default)]
3680 projection_sha256: Option<String>,
3681 files: std::collections::BTreeMap<String, V2BaselineFile>,
3682 #[serde(default)]
3683 local_policy_digest: Option<String>,
3684 #[serde(default)]
3685 local_eligibility: std::collections::BTreeMap<String, bool>,
3686 #[serde(default)]
3687 remote_copy_remains: std::collections::BTreeMap<String, String>,
3688}
3689
3690struct V2LocalView {
3691 riding: std::collections::BTreeMap<String, (String, u64)>,
3692 eligibility: std::collections::BTreeMap<String, bool>,
3693 policy: crate::linkmd_sync_policy::SyncPolicy,
3694 withheld_links: Vec<V2WithheldLink>,
3695}
3696
3697#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3698struct V2WithheldLink {
3699 source: String,
3700 target: String,
3701}
3702
3703#[derive(Debug, Clone, Deserialize, Serialize)]
3704struct V2ProofStep {
3705 directory_root: String,
3706 component: String,
3707 proof: crate::linkmd_v2::HamtProof,
3708}
3709
3710#[derive(Debug, Deserialize)]
3711struct V2ManifestFile {
3712 path: String,
3713 sha256: String,
3714 bytes: u64,
3715 proof: Vec<V2ProofStep>,
3716}
3717
3718#[derive(Debug, Deserialize)]
3719struct V2ManifestPage {
3720 v: u8,
3721 commit: String,
3722 content_root: Option<String>,
3723 files: Vec<V2ManifestFile>,
3724 next_cursor: Option<String>,
3725}
3726
3727#[derive(Debug, Clone, Deserialize, Serialize)]
3728struct V2BaselineAsset {
3729 blob_sha256: String,
3730 bytes: u64,
3731 media_type: String,
3732 wrappers: Vec<String>,
3733 required: bool,
3734 disposition: String,
3735 leaf_hash: String,
3736}
3737
3738#[derive(Debug, Deserialize)]
3739struct V2AssetManifestItem {
3740 path: String,
3741 blob_sha256: String,
3742 bytes: u64,
3743 media_type: String,
3744 wrappers: Vec<String>,
3745 required: bool,
3746 disposition: String,
3747 leaf_hash: String,
3748 proof: crate::linkmd_v2::HamtProof,
3749}
3750
3751#[derive(Debug, Deserialize)]
3752struct V2AssetManifestPage {
3753 v: u8,
3754 commit: String,
3755 asset_root: Option<String>,
3756 assets: Vec<V2AssetManifestItem>,
3757 next_cursor: Option<String>,
3758}
3759
3760#[derive(Debug, Deserialize)]
3761struct V2SigningCandidate {
3762 seq: u64,
3763 content_root: Option<String>,
3764 asset_root: Option<String>,
3765 signing_bytes_base64: String,
3766 changes_base64: String,
3767 actor_claim_base64: String,
3768}
3769
3770#[derive(Debug, Deserialize)]
3771struct V2SigningCandidatePage {
3772 v: u8,
3773 challenge_id: String,
3774 mutation_id: String,
3775 request_hash: String,
3776 parent: V2SigningParent,
3777 candidate: V2SigningCandidate,
3778 files: Vec<V2ManifestFile>,
3779 #[serde(default)]
3780 assets: Vec<V2AssetManifestItem>,
3781 next_cursor: Option<String>,
3782 expires_at: String,
3783}
3784
3785#[derive(Debug, Deserialize)]
3786struct V2SigningParent {
3787 seq: u64,
3788 commit_hash: Option<String>,
3789}
3790
3791fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3792 let normalized = crate::linkmd_v2::normalize_path(&file.path)
3793 .map_err(|error| invalid_feed(error.to_string()))?;
3794 let components = normalized.split('/').collect::<Vec<_>>();
3795 if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3796 return Err(invalid_feed("v2 file proof has the wrong shape"));
3797 }
3798 let mut directory_root = root.to_string();
3799 for (index, step) in file.proof.iter().enumerate() {
3800 if step.directory_root != directory_root || step.component != components[index] {
3801 return Err(invalid_feed(
3802 "v2 file proof path chain differs from its manifest",
3803 ));
3804 }
3805 if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3806 .map_err(|error| invalid_feed(error.to_string()))?
3807 {
3808 return Err(invalid_feed("v2 file proof failed verification"));
3809 }
3810 let entry = match &step.proof {
3811 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3812 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3813 return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3814 }
3815 };
3816 if index + 1 == components.len() {
3817 if entry.kind != crate::linkmd_v2::EntryKind::Blob
3818 || entry.child_hash != file.sha256
3819 || entry.bytes != Some(file.bytes)
3820 {
3821 return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3822 }
3823 } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3824 return Err(invalid_feed("v2 file proof traversed a non-directory"));
3825 } else {
3826 directory_root = entry.child_hash.clone();
3827 }
3828 }
3829 Ok(())
3830}
3831
3832fn v2_manifest(
3833 cfg: &HubConfig,
3834 brain: &str,
3835 pointer: Option<&V2PointerBody>,
3836) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3837 let Some(pointer) = pointer else {
3838 return Ok(std::collections::BTreeMap::new());
3839 };
3840 let Some(root) = pointer.content_root.as_deref() else {
3841 return Ok(std::collections::BTreeMap::new());
3842 };
3843 let mut files = std::collections::BTreeMap::new();
3844 let mut after = String::new();
3845 loop {
3846 let encoded_after: String =
3847 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3848 let path = format!(
3849 "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3850 pointer.commit_hash
3851 );
3852 let value = ensure_ok(
3853 request_capped(
3854 cfg,
3855 "GET",
3856 &path,
3857 None,
3858 Auth::Required,
3859 MAX_FEED_RESPONSE_BYTES,
3860 )?,
3861 "v2 file manifest",
3862 )?;
3863 let page: V2ManifestPage = serde_json::from_value(value)
3864 .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3865 if page.v != 2
3866 || page.commit != pointer.commit_hash
3867 || page.content_root.as_deref() != Some(root)
3868 || page.files.len() > 500
3869 {
3870 return Err(invalid_feed(
3871 "v2 file manifest is not bound to the verified head",
3872 ));
3873 }
3874 for file in page.files {
3875 verify_v2_file_proof(root, &file)?;
3876 if files
3877 .insert(
3878 file.path.clone(),
3879 V2BaselineFile {
3880 sha256: file.sha256,
3881 bytes: file.bytes,
3882 proof: Some(file.proof),
3883 },
3884 )
3885 .is_some()
3886 {
3887 return Err(invalid_feed("v2 file manifest repeats a path"));
3888 }
3889 if files.len() > MAX_PUSH_FILES {
3890 return Err(invalid_feed(
3891 "v2 file manifest exceeds the file-count bound",
3892 ));
3893 }
3894 }
3895 match page.next_cursor {
3896 None => break,
3897 Some(next) if next > after => after = next,
3898 Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3899 }
3900 }
3901 Ok(files)
3902}
3903
3904fn v2_manifest_file(
3909 cfg: &HubConfig,
3910 brain: &str,
3911 pointer: &V2PointerBody,
3912 path: &str,
3913) -> LinkResult<Option<V2BaselineFile>> {
3914 let Some(root) = pointer.content_root.as_deref() else {
3915 return Ok(None);
3916 };
3917 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
3918 path: error.to_string(),
3919 })?;
3920 let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
3921 let response = request_capped(
3922 cfg,
3923 "GET",
3924 &format!(
3925 "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
3926 pointer.commit_hash
3927 ),
3928 None,
3929 Auth::Required,
3930 MAX_FEED_RESPONSE_BYTES,
3931 )?;
3932 if response.status == 404 {
3936 return Ok(None);
3937 }
3938 let value = ensure_ok(response, "v2 exact file proof")?;
3939 let mut page: V2ManifestPage = serde_json::from_value(value)
3940 .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
3941 if page.v != 2
3942 || page.commit != pointer.commit_hash
3943 || page.content_root.as_deref() != Some(root)
3944 || page.next_cursor.is_some()
3945 || page.files.len() != 1
3946 || page.files[0].path != path
3947 {
3948 return Err(invalid_feed(
3949 "v2 exact file proof is not bound to the requested signed path",
3950 ));
3951 }
3952 let file = page.files.pop().expect("exactly one file was checked");
3953 verify_v2_file_proof(root, &file)?;
3954 Ok(Some(V2BaselineFile {
3955 sha256: file.sha256,
3956 bytes: file.bytes,
3957 proof: Some(file.proof),
3958 }))
3959}
3960
3961fn v2_manifest_file_by_id(
3966 cfg: &HubConfig,
3967 brain: &str,
3968 pointer: &V2PointerBody,
3969 id: &str,
3970) -> LinkResult<(String, V2BaselineFile)> {
3971 let root = pointer
3972 .content_root
3973 .as_deref()
3974 .ok_or_else(|| LinkError::Http {
3975 what: "resolve",
3976 status: 404,
3977 message: "record not found".to_string(),
3978 code: Some("NOT_FOUND".to_string()),
3979 details: None,
3980 })?;
3981 if !crate::ulid::is_ulid(id) {
3982 return Err(LinkError::BadAddress {
3983 given: id.to_string(),
3984 reason: BAD_TARGET_REASON.to_string(),
3985 });
3986 }
3987 let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
3988 let value = ensure_ok(
3989 request_capped(
3990 cfg,
3991 "GET",
3992 &format!(
3993 "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
3994 pointer.commit_hash
3995 ),
3996 None,
3997 Auth::Required,
3998 MAX_FEED_RESPONSE_BYTES,
3999 )?,
4000 "v2 exact id proof",
4001 )?;
4002 let mut page: V2ManifestPage = serde_json::from_value(value)
4003 .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
4004 if page.v != 2
4005 || page.commit != pointer.commit_hash
4006 || page.content_root.as_deref() != Some(root)
4007 || page.next_cursor.is_some()
4008 || page.files.len() != 1
4009 {
4010 return Err(invalid_feed(
4011 "v2 exact id proof is not bound to one signed path",
4012 ));
4013 }
4014 let file = page.files.pop().expect("exactly one file was checked");
4015 if !safe_store_rel_path(&file.path)
4016 || !file.path.ends_with(".md")
4017 || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
4018 {
4019 return Err(invalid_feed("v2 exact id proof has an invalid record path"));
4020 }
4021 verify_v2_file_proof(root, &file)?;
4022 Ok((
4023 file.path,
4024 V2BaselineFile {
4025 sha256: file.sha256,
4026 bytes: file.bytes,
4027 proof: Some(file.proof),
4028 },
4029 ))
4030}
4031
4032fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
4033 crate::linkmd_v2::normalize_path(&item.path)
4034 .map_err(|error| invalid_feed(error.to_string()))?;
4035 if !is_sha256(&item.blob_sha256)
4036 || !is_sha256(&item.leaf_hash)
4037 || item.bytes > MAX_ASSET_BYTES
4038 || item.wrappers.is_empty()
4039 || !matches!(item.disposition.as_str(), "hosted" | "withheld")
4040 || item
4041 .wrappers
4042 .iter()
4043 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4044 {
4045 return Err(invalid_feed("v2 asset manifest item is invalid"));
4046 }
4047 let leaf = json!({
4048 "blob_sha256": item.blob_sha256,
4049 "bytes": item.bytes,
4050 "disposition": item.disposition,
4051 "media_type": item.media_type,
4052 "path": item.path,
4053 "required": item.required,
4054 "v": 2,
4055 "wrappers": item.wrappers,
4056 });
4057 if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
4058 .map_err(|error| invalid_feed(error.to_string()))?
4059 != item.leaf_hash
4060 || !crate::linkmd_v2::verify_proof_with_domain(
4061 root,
4062 &item.path,
4063 &item.proof,
4064 crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
4065 )
4066 .map_err(|error| invalid_feed(error.to_string()))?
4067 {
4068 return Err(invalid_feed("v2 asset inclusion proof failed"));
4069 }
4070 match &item.proof {
4071 crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
4072 if entry.name == item.path
4073 && entry.kind == crate::linkmd_v2::EntryKind::Blob
4074 && entry.child_hash == item.leaf_hash
4075 && entry.bytes == Some(item.bytes) =>
4076 {
4077 Ok(())
4078 }
4079 _ => Err(invalid_feed(
4080 "v2 asset proof leaf differs from its manifest",
4081 )),
4082 }
4083}
4084
4085fn v2_asset_manifest(
4086 cfg: &HubConfig,
4087 brain: &str,
4088 pointer: Option<&V2PointerBody>,
4089) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
4090 let Some(pointer) = pointer else {
4091 return Ok(std::collections::BTreeMap::new());
4092 };
4093 let Some(root) = pointer.asset_root.as_deref() else {
4094 return Ok(std::collections::BTreeMap::new());
4095 };
4096 let mut assets = std::collections::BTreeMap::new();
4097 let mut after = String::new();
4098 loop {
4099 let encoded_after: String =
4100 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4101 let path = format!(
4102 "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4103 pointer.commit_hash
4104 );
4105 let value = ensure_ok(
4106 request_capped(
4107 cfg,
4108 "GET",
4109 &path,
4110 None,
4111 Auth::Required,
4112 MAX_FEED_RESPONSE_BYTES,
4113 )?,
4114 "v2 asset manifest",
4115 )?;
4116 let page: V2AssetManifestPage = serde_json::from_value(value)
4117 .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4118 if page.v != 2
4119 || page.commit != pointer.commit_hash
4120 || page.asset_root.as_deref() != Some(root)
4121 || page.assets.len() > 500
4122 {
4123 return Err(invalid_feed(
4124 "v2 asset manifest is not bound to the verified head",
4125 ));
4126 }
4127 for item in page.assets {
4128 verify_v2_asset_proof(root, &item)?;
4129 let path = item.path.clone();
4130 if assets
4131 .insert(
4132 path,
4133 V2BaselineAsset {
4134 blob_sha256: item.blob_sha256,
4135 bytes: item.bytes,
4136 media_type: item.media_type,
4137 wrappers: item.wrappers,
4138 required: item.required,
4139 disposition: item.disposition,
4140 leaf_hash: item.leaf_hash,
4141 },
4142 )
4143 .is_some()
4144 {
4145 return Err(invalid_feed("v2 asset manifest repeats a path"));
4146 }
4147 if assets.len() > MAX_PUSH_FILES {
4148 return Err(invalid_feed(
4149 "v2 asset manifest exceeds the item-count bound",
4150 ));
4151 }
4152 }
4153 match page.next_cursor {
4154 None => break,
4155 Some(next) if next > after => after = next,
4156 Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4157 }
4158 }
4159 Ok(assets)
4160}
4161
4162fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4163 crate::AssetRecord {
4164 path: path.to_string(),
4165 sha256: asset.blob_sha256.clone(),
4166 bytes: asset.bytes,
4167 media_type: asset.media_type.clone(),
4168 wrappers: asset.wrappers.clone(),
4169 required: asset.required,
4170 }
4171}
4172
4173fn v2_asset_resumes_hosting(
4174 remote: Option<&V2BaselineAsset>,
4175 path: &str,
4176 record: &crate::AssetRecord,
4177 disposition: &str,
4178) -> bool {
4179 remote.is_some_and(|asset| {
4180 asset.disposition == "withheld"
4181 && disposition == "hosted"
4182 && v2_asset_record(asset, path) == *record
4183 })
4184}
4185
4186fn v2_asset_inherits_withheld_absence(
4187 base: Option<&V2BaselineAsset>,
4188 base_record: Option<&crate::AssetRecord>,
4189 local_record: Option<&crate::AssetRecord>,
4190 raw_present: bool,
4191) -> bool {
4192 !raw_present
4193 && base.is_some_and(|asset| asset.disposition == "withheld")
4194 && local_record == base_record
4195}
4196
4197fn v2_asset_record_manifest_bytes(
4198 assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4199) -> LinkResult<Vec<u8>> {
4200 let mut bytes = Vec::new();
4201 for (path, asset) in assets {
4202 if asset.path != *path {
4203 return Err(invalid_feed(
4204 "local asset manifest key differs from its record path",
4205 ));
4206 }
4207 serde_json::to_writer(&mut bytes, asset)
4208 .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4209 bytes.push(b'\n');
4210 }
4211 Ok(bytes)
4212}
4213
4214fn v2_local_asset_records(
4215 store: &Store,
4216) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4217 let assets = crate::assets::read_manifest(store)
4218 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?;
4219 if assets.iter().any(|asset| asset.bytes > MAX_ASSET_BYTES) {
4220 return Err(LinkError::InvalidPack {
4221 message: "local asset exceeds the link.md v2 per-blob limit".to_string(),
4222 });
4223 }
4224 Ok(assets
4225 .into_iter()
4226 .map(|asset| (asset.path.clone(), asset))
4227 .collect())
4228}
4229
4230fn v2_asset_records_match_remote(
4231 local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4232 remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4233) -> bool {
4234 local.len() == remote.len()
4235 && remote
4236 .iter()
4237 .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4238}
4239
4240#[derive(Debug, Clone, PartialEq, Eq)]
4241struct V2PulledMerge<T> {
4242 records: std::collections::BTreeMap<String, T>,
4243 accept_remote: std::collections::BTreeSet<String>,
4244 conflicts: Vec<String>,
4245}
4246
4247fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4253 base: &std::collections::BTreeMap<String, Base>,
4254 remote: &std::collections::BTreeMap<String, Remote>,
4255 local: &std::collections::BTreeMap<String, Record>,
4256 base_record: BaseRecord,
4257 remote_record: RemoteRecord,
4258 keep_local: KeepLocal,
4259) -> V2PulledMerge<Record>
4260where
4261 Record: Clone + Eq,
4262 BaseRecord: Fn(&Base, &str) -> Record,
4263 RemoteRecord: Fn(&Remote, &str) -> Record,
4264 KeepLocal: Fn(&str) -> bool,
4265{
4266 let paths = base
4267 .keys()
4268 .chain(remote.keys())
4269 .chain(local.keys())
4270 .cloned()
4271 .collect::<std::collections::BTreeSet<_>>();
4272 let mut records = local.clone();
4273 let mut accept_remote = std::collections::BTreeSet::new();
4274 let mut conflicts = Vec::new();
4275 for path in paths {
4276 if keep_local(&path) {
4277 continue;
4278 }
4279 let base_value = base.get(&path).map(|value| base_record(value, &path));
4280 let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4281 let local_value = local.get(&path).cloned();
4282 if local_value != base_value && remote_value != base_value && local_value != remote_value {
4283 conflicts.push(path);
4284 continue;
4285 }
4286 if local_value == base_value || local_value == remote_value {
4287 accept_remote.insert(path.clone());
4288 match remote_value {
4289 Some(value) => {
4290 records.insert(path, value);
4291 }
4292 None => {
4293 records.remove(&path);
4294 }
4295 }
4296 }
4297 }
4298 V2PulledMerge {
4299 records,
4300 accept_remote,
4301 conflicts,
4302 }
4303}
4304
4305fn sign_verified_v2_candidate(
4306 cfg: &HubConfig,
4307 head: &V2VerifiedHead,
4308 expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4309 expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4310 mutation_id: &str,
4311 request_body: &Value,
4312 challenge_value: &Value,
4313) -> LinkResult<(String, String, String)> {
4314 if head.view_kind != "full" {
4315 return Err(invalid_feed(
4316 "a scoped self-custody writer must use the proposal workflow",
4317 ));
4318 }
4319 if head.identity.custody != "self" {
4320 return Err(invalid_feed(
4321 "a hub-custodied brain unexpectedly requested an external signature",
4322 ));
4323 }
4324 let key = cfg
4325 .brain_key
4326 .as_ref()
4327 .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4328 if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4329 || key.public_key_spki != head.identity.public_key_spki
4330 {
4331 return Err(bad_agent_key(
4332 "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4333 ));
4334 }
4335 let challenge_id = challenge_value
4336 .get("id")
4337 .and_then(Value::as_str)
4338 .filter(|id| crate::ulid::is_ulid(id))
4339 .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4340 let expected_endpoint = format!(
4341 "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4342 head.brain_id
4343 );
4344 if challenge_value
4345 .get("candidate_endpoint")
4346 .and_then(Value::as_str)
4347 != Some(expected_endpoint.as_str())
4348 {
4349 return Err(invalid_feed(
4350 "self-custody challenge candidate endpoint is not origin-bound",
4351 ));
4352 }
4353
4354 let mut files = std::collections::BTreeMap::new();
4355 let mut after = String::new();
4356 type CandidateCoordinate = (
4357 String,
4358 String,
4359 String,
4360 String,
4361 Option<String>,
4362 Option<String>,
4363 u64,
4364 Option<String>,
4365 );
4366 let mut pinned: Option<CandidateCoordinate> = None;
4367 loop {
4368 let encoded_after: String =
4369 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4370 let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4371 let value = ensure_ok(
4372 request_capped(
4373 cfg,
4374 "GET",
4375 &path,
4376 None,
4377 Auth::Required,
4378 MAX_FEED_RESPONSE_BYTES,
4379 )?,
4380 "v2 self-custody candidate",
4381 )?;
4382 let page: V2SigningCandidatePage = serde_json::from_value(value)
4383 .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4384 if page.v != 2
4385 || page.challenge_id != challenge_id
4386 || page.mutation_id != mutation_id
4387 || page.candidate.seq != page.parent.seq + 1
4388 || page.files.len() > 500
4389 || page.expires_at.is_empty()
4390 {
4391 return Err(invalid_feed(
4392 "self-custody candidate is not bound to this mutation",
4393 ));
4394 }
4395 let coordinate = (
4396 page.request_hash.clone(),
4397 page.candidate.signing_bytes_base64.clone(),
4398 page.candidate.changes_base64.clone(),
4399 page.candidate.actor_claim_base64.clone(),
4400 page.candidate.content_root.clone(),
4401 page.candidate.asset_root.clone(),
4402 page.parent.seq,
4403 page.parent.commit_hash.clone(),
4404 );
4405 if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4406 return Err(invalid_feed(
4407 "self-custody candidate changed between manifest pages",
4408 ));
4409 }
4410 pinned = Some(coordinate);
4411 let root = page
4412 .candidate
4413 .content_root
4414 .as_deref()
4415 .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4416 for file in page.files {
4417 verify_v2_file_proof(root, &file)?;
4418 if files
4419 .insert(
4420 file.path.clone(),
4421 V2BaselineFile {
4422 sha256: file.sha256,
4423 bytes: file.bytes,
4424 proof: Some(file.proof),
4425 },
4426 )
4427 .is_some()
4428 {
4429 return Err(invalid_feed(
4430 "self-custody candidate repeats a manifest path",
4431 ));
4432 }
4433 if files.len() > MAX_PUSH_FILES {
4434 return Err(invalid_feed(
4435 "self-custody candidate exceeds the file-count bound",
4436 ));
4437 }
4438 }
4439 match page.next_cursor {
4440 None => break,
4441 Some(next) if next > after => after = next,
4442 Some(_) => {
4443 return Err(invalid_feed(
4444 "self-custody candidate cursor did not advance",
4445 ))
4446 }
4447 }
4448 }
4449 if files.len() != expected.len()
4450 || files.iter().any(|(path, file)| {
4451 expected.get(path).is_none_or(|expected| {
4452 expected.sha256 != file.sha256 || expected.bytes != file.bytes
4453 })
4454 })
4455 {
4456 return Err(invalid_feed(
4457 "self-custody candidate contains an unexpected file mutation",
4458 ));
4459 }
4460 let mut assets = std::collections::BTreeMap::new();
4461 after.clear();
4462 loop {
4463 let encoded_after: String =
4464 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4465 let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4466 let value = ensure_ok(
4467 request_capped(
4468 cfg,
4469 "GET",
4470 &path,
4471 None,
4472 Auth::Required,
4473 MAX_FEED_RESPONSE_BYTES,
4474 )?,
4475 "v2 self-custody asset candidate",
4476 )?;
4477 let page: V2SigningCandidatePage = serde_json::from_value(value)
4478 .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4479 let coordinate = (
4480 page.request_hash.clone(),
4481 page.candidate.signing_bytes_base64.clone(),
4482 page.candidate.changes_base64.clone(),
4483 page.candidate.actor_claim_base64.clone(),
4484 page.candidate.content_root.clone(),
4485 page.candidate.asset_root.clone(),
4486 page.parent.seq,
4487 page.parent.commit_hash.clone(),
4488 );
4489 if page.v != 2
4490 || page.challenge_id != challenge_id
4491 || page.mutation_id != mutation_id
4492 || page.assets.len() > 500
4493 || pinned.as_ref() != Some(&coordinate)
4494 {
4495 return Err(invalid_feed(
4496 "self-custody asset candidate changed or is not bound",
4497 ));
4498 }
4499 let root = page.candidate.asset_root.as_deref();
4500 if !page.assets.is_empty() && root.is_none() {
4501 return Err(invalid_feed("asset candidate has no asset root"));
4502 }
4503 for item in page.assets {
4504 verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4505 if assets
4506 .insert(
4507 item.path.clone(),
4508 V2BaselineAsset {
4509 blob_sha256: item.blob_sha256,
4510 bytes: item.bytes,
4511 media_type: item.media_type,
4512 wrappers: item.wrappers,
4513 required: item.required,
4514 disposition: item.disposition,
4515 leaf_hash: item.leaf_hash,
4516 },
4517 )
4518 .is_some()
4519 {
4520 return Err(invalid_feed("self-custody candidate repeats an asset"));
4521 }
4522 }
4523 match page.next_cursor {
4524 None => break,
4525 Some(next) if next > after => after = next,
4526 Some(_) => {
4527 return Err(invalid_feed(
4528 "self-custody asset candidate cursor did not advance",
4529 ))
4530 }
4531 }
4532 }
4533 if assets.len() != expected_assets.len()
4534 || assets.iter().any(|(path, asset)| {
4535 expected_assets.get(path).is_none_or(|expected| {
4536 asset.blob_sha256 != expected.blob_sha256
4537 || asset.bytes != expected.bytes
4538 || asset.media_type != expected.media_type
4539 || asset.wrappers != expected.wrappers
4540 || asset.required != expected.required
4541 || asset.disposition != expected.disposition
4542 })
4543 })
4544 {
4545 return Err(invalid_feed(
4546 "self-custody candidate contains an unexpected asset mutation",
4547 ));
4548 }
4549 let Some((
4550 request_hash,
4551 signing_b64,
4552 changes_b64,
4553 actor_b64,
4554 root,
4555 asset_root,
4556 parent_seq,
4557 parent,
4558 )) = pinned
4559 else {
4560 return Err(invalid_feed("self-custody candidate has no manifest"));
4561 };
4562 let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4563 let current_commit = head
4564 .pointer
4565 .as_ref()
4566 .map(|pointer| pointer.commit_hash.clone());
4567 if parent_seq != current_seq || parent != current_commit {
4568 return Err(LinkError::RemoteAdvancedDuringSync);
4569 }
4570 let changes = STANDARD
4571 .decode(changes_b64)
4572 .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4573 let mut expected_changes = json!({
4574 "mutation_id": mutation_id,
4575 "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4576 "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4577 "v": 2,
4578 });
4579 if let Some(withheld_links) = request_body.get("withheld_links") {
4580 expected_changes["withheld_links"] = withheld_links.clone();
4581 }
4582 if let Some(checkout_id) = request_body.get("checkout_id") {
4583 expected_changes["checkout_id"] = checkout_id.clone();
4584 }
4585 let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4586 .map_err(|error| invalid_feed(error.to_string()))?;
4587 if changes != expected_changes_bytes {
4588 return Err(invalid_feed(
4589 "self-custody changeset differs from the requested mutation",
4590 ));
4591 }
4592 let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4593 .map_err(|error| invalid_feed(error.to_string()))?;
4594 let request_value = json!({
4595 "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4596 "brain": head.brain_id,
4597 "changes_sha256": changes_hash,
4598 "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4599 "v": 2,
4600 "v1_bridge": Value::Null,
4601 });
4602 let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4603 .map_err(|error| invalid_feed(error.to_string()))?;
4604 if request_hash != expected_request_hash {
4605 return Err(invalid_feed(
4606 "self-custody request hash differs from the requested mutation",
4607 ));
4608 }
4609 let actor = STANDARD
4610 .decode(actor_b64)
4611 .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4612 let actor_value: Value = serde_json::from_slice(&actor)
4613 .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4614 if crate::linkmd_v2::canonical_bytes(&actor_value)
4615 .map_err(|error| invalid_feed(error.to_string()))?
4616 != actor
4617 {
4618 return Err(invalid_feed("self-custody actor claim is not canonical"));
4619 }
4620 let actor_object = actor_value
4621 .as_object()
4622 .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4623 let actor_claim = actor_object
4624 .get("claim")
4625 .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4626 let actor_public_key = actor_object
4627 .get("public_key")
4628 .and_then(Value::as_str)
4629 .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4630 let actor_fingerprint = actor_object
4631 .get("fingerprint")
4632 .and_then(Value::as_str)
4633 .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4634 let actor_signature = actor_object
4635 .get("sig")
4636 .and_then(Value::as_str)
4637 .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4638 let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4639 .map_err(|error| invalid_feed(error.to_string()))?;
4640 let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4641 let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4642 let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4643 let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4644 let impact = actor_claim
4645 .get("result")
4646 .and_then(|result| result.get("impact"))
4647 .and_then(Value::as_object);
4648 let impact_fields = [
4649 "creates",
4650 "updates",
4651 "deletes",
4652 "withdrawals",
4653 "renames",
4654 "restores",
4655 "asset_changes",
4656 "public_expansions",
4657 "executable_activations",
4658 ];
4659 let impact_is_valid = impact.is_some_and(|impact| {
4660 impact.len() == impact_fields.len() + 1
4661 && impact.get("v").and_then(Value::as_u64) == Some(1)
4662 && impact_fields
4663 .iter()
4664 .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4665 });
4666 if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4667 || head
4668 .trust
4669 .hub_signer
4670 .as_ref()
4671 .is_some_and(|known| known != &expected_actor_signer)
4672 || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4673 || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4674 || actor_claim
4675 .get("candidate")
4676 .and_then(|candidate| candidate.get("changes_sha256"))
4677 .and_then(Value::as_str)
4678 != Some(changes_hash.as_str())
4679 || actor_claim
4680 .get("candidate")
4681 .and_then(|candidate| candidate.get("state_root"))
4682 != Some(&expected_actor_root)
4683 || actor_claim
4684 .get("candidate")
4685 .and_then(|candidate| candidate.get("asset_root"))
4686 != Some(&expected_actor_asset_root)
4687 || actor_claim
4688 .get("candidate")
4689 .and_then(|candidate| candidate.get("control_revision"))
4690 .and_then(Value::as_str)
4691 != Some(head.control_revision.as_str())
4692 || !impact_is_valid
4693 {
4694 return Err(invalid_feed(
4695 "self-custody actor claim does not bind the verified authority",
4696 ));
4697 }
4698 let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4699 .map_err(|error| invalid_feed(error.to_string()))?;
4700 let signing = STANDARD
4701 .decode(signing_b64)
4702 .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4703 let signing_value: Value = serde_json::from_slice(&signing)
4704 .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4705 if crate::linkmd_v2::canonical_bytes(&signing_value)
4706 .map_err(|error| invalid_feed(error.to_string()))?
4707 != signing
4708 {
4709 return Err(invalid_feed("self-custody signing bytes are not canonical"));
4710 }
4711 let pointer = head.pointer.as_ref();
4712 let expected_materializer = pointer
4713 .map(|value| value.materializer.as_str())
4714 .unwrap_or("dbmd-projection-v1");
4715 let expected_parent_commit = request_body
4716 .get("base")
4717 .and_then(|base| base.get("commit_hash"))
4718 .cloned()
4719 .unwrap_or(Value::Null);
4720 let expected_parent_root = request_body
4721 .get("base")
4722 .and_then(|base| base.get("content_root"))
4723 .cloned()
4724 .unwrap_or(Value::Null);
4725 let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4726 let expected_parent_asset_root = request_body
4727 .get("base")
4728 .and_then(|base| base.get("asset_root"))
4729 .cloned()
4730 .unwrap_or(Value::Null);
4731 let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4732 let expected_prev_entry = pointer
4733 .map(|value| Value::String(value.feed_hash.clone()))
4734 .unwrap_or(Value::Null);
4735 let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4736 .map_err(|_| invalid_feed("brain identity history is too large"))?
4737 + 1;
4738 if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4739 || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4740 || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4741 || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4742 || signing_value.get("public_key").and_then(Value::as_str)
4743 != Some(key.public_key_spki.as_str())
4744 || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4745 || signing_value.get("parent_root") != Some(&expected_parent_root)
4746 || signing_value.get("state_root") != Some(&expected_state_root)
4747 || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4748 || signing_value.get("asset_root") != Some(&expected_asset_root)
4749 || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4750 || signing_value.get("changes_sha256").and_then(Value::as_str)
4751 != Some(changes_hash.as_str())
4752 || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4753 || signing_value
4754 .get("control_revision")
4755 .and_then(Value::as_str)
4756 != Some(head.control_revision.as_str())
4757 || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4758 || signing_value.get("v1_bridge") != Some(&Value::Null)
4759 || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4760 {
4761 return Err(invalid_feed(
4762 "self-custody signing bytes do not bind the verified candidate",
4763 ));
4764 }
4765 let pair = agent_keypair(&key.pkcs8)?;
4766 let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4767 Ok((challenge_id.to_string(), signature, expected_actor_signer))
4768}
4769
4770fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4771 let origin = normalized_origin(&cfg.hub)?;
4772 let absolute = if checkout.is_absolute() {
4773 checkout.to_path_buf()
4774 } else {
4775 std::env::current_dir()?.join(checkout)
4776 };
4777 Ok(format!(
4778 "sync-{}.json",
4779 content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4780 ))
4781}
4782
4783fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4784 if let Some(value) = existing {
4785 if !is_sha256(value) {
4786 return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4787 }
4788 return Ok(value.to_string());
4789 }
4790 use ring::rand::SecureRandom as _;
4791 let mut random = [0_u8; 32];
4792 ring::rand::SystemRandom::new()
4793 .fill(&mut random)
4794 .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4795 Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4796}
4797
4798#[cfg(any(unix, windows))]
4799fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4800 let directory = open_trust_dir(cfg)?;
4801 let origin = normalized_origin(&cfg.hub)?;
4802 let name = format!(
4803 "operation-{}.lock",
4804 content_sha256(format!("{origin}\0{brain}").as_bytes())
4805 );
4806 lock_trust_name(&directory, &name)
4807}
4808
4809#[cfg(not(any(unix, windows)))]
4810fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4811 Err(LinkError::UnsupportedPlatform {
4812 operation: "serialized link.md v2 sync",
4813 })
4814}
4815
4816fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4817 left.brain_id == right.brain_id
4818 && left.view_kind == right.view_kind
4819 && left.view_revision == right.view_revision
4820 && left.control_revision == right.control_revision
4821 && match (&left.pointer, &right.pointer) {
4822 (None, None) => true,
4823 (Some(left), Some(right)) => {
4824 left.seq == right.seq
4825 && left.commit_hash == right.commit_hash
4826 && left.content_root == right.content_root
4827 && left.asset_root == right.asset_root
4828 && left.feed_hash == right.feed_hash
4829 }
4830 _ => false,
4831 }
4832}
4833
4834fn v2_baseline_matches_head(head: &V2VerifiedHead, baseline: &V2SyncBaseline) -> bool {
4840 let pointer = head.pointer.as_ref();
4841 baseline.head_seq == Some(pointer.map_or(0, |value| value.seq))
4842 && baseline.commit_hash.as_deref() == pointer.map(|value| value.commit_hash.as_str())
4843 && baseline.content_root.as_deref()
4844 == pointer.and_then(|value| value.content_root.as_deref())
4845 && baseline.asset_root.as_deref() == pointer.and_then(|value| value.asset_root.as_deref())
4846 && baseline.view_kind.as_deref() == Some(head.view_kind.as_str())
4847 && baseline.view_revision.as_deref() == Some(head.view_revision.as_str())
4848 && baseline.control_revision.as_deref() == Some(head.control_revision.as_str())
4849}
4850
4851fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4852 format!(
4853 "---\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"
4854 )
4855 .into_bytes()
4856}
4857
4858fn scoped_projection_sha256(brain: &str) -> String {
4859 content_sha256(&scoped_projection_bytes(brain))
4860}
4861
4862#[derive(Deserialize)]
4863struct LocalScopedViewMarker {
4864 v: u8,
4865 kind: String,
4866 authoritative: bool,
4867 brain: String,
4868 projection_sha256: String,
4869}
4870
4871pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4875 let marker = store
4876 .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4877 .ok()
4878 .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4879 let Some(marker) = marker else {
4880 return false;
4881 };
4882 if marker.v != 1
4883 || marker.kind != "link.md-scoped-view"
4884 || marker.authoritative
4885 || !crate::ulid::is_ulid(&marker.brain)
4886 || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4887 {
4888 return false;
4889 }
4890 store
4891 .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4892 .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4893}
4894
4895fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4896 let mut bytes = serde_json::to_vec_pretty(&json!({
4897 "v": 1,
4898 "kind": "link.md-scoped-view",
4899 "authoritative": false,
4900 "brain": head.brain_id,
4901 "view_revision": head.view_revision,
4902 "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4903 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4904 "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4905 "visible_files": files,
4906 "projection_sha256": scoped_projection_sha256(&head.brain_id),
4907 }))
4908 .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4909 bytes.push(b'\n');
4910 Ok(bytes)
4911}
4912
4913fn refresh_scoped_view_marker(
4914 store: &Store,
4915 head: &V2VerifiedHead,
4916 files: usize,
4917) -> LinkResult<()> {
4918 if head.view_kind == "scoped" {
4919 store.write_atomic(
4920 Path::new(".dbmd/view.json"),
4921 &scoped_view_metadata(head, files)?,
4922 )?;
4923 }
4924 Ok(())
4925}
4926
4927fn ensure_v2_view_compatible(
4928 head: &V2VerifiedHead,
4929 baseline: Option<&V2SyncBaseline>,
4930) -> LinkResult<()> {
4931 let Some(baseline) = baseline else {
4932 return Ok(());
4933 };
4934 match (
4935 baseline.view_kind.as_deref(),
4936 baseline.view_revision.as_deref(),
4937 ) {
4938 (None, None) if head.view_kind == "full" => Ok(()),
4939 (Some(kind), Some(revision))
4940 if kind == head.view_kind && revision == head.view_revision =>
4941 {
4942 Ok(())
4943 }
4944 _ => Err(LinkError::ScopedViewChanged),
4945 }
4946}
4947
4948fn ensure_established_v2_checkout_opened(
4949 head: &V2VerifiedHead,
4950 baseline: Option<&V2SyncBaseline>,
4951 opened: bool,
4952) -> LinkResult<()> {
4953 if baseline.is_none() || opened {
4954 return Ok(());
4955 }
4956 if head.view_kind == "scoped" {
4957 return Err(LinkError::ScopedProjectionModified);
4958 }
4959 Err(LinkError::InvalidPack {
4960 message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4961 })
4962}
4963
4964fn remove_scoped_projection(
4965 head: &V2VerifiedHead,
4966 baseline: Option<&V2SyncBaseline>,
4967 view: &mut V2LocalView,
4968) -> LinkResult<()> {
4969 if head.view_kind != "scoped" {
4970 return Ok(());
4971 }
4972 let expected = scoped_projection_sha256(&head.brain_id);
4973 if baseline
4974 .and_then(|state| state.projection_sha256.as_deref())
4975 .is_some_and(|pinned| pinned != expected)
4976 {
4977 return Err(LinkError::ScopedViewChanged);
4978 }
4979 if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4980 return Err(LinkError::ScopedProjectionModified);
4981 }
4982 view.riding.remove("DB.md");
4983 view.eligibility.remove("DB.md");
4984 Ok(())
4985}
4986
4987fn local_view_for_v2_push(
4988 store: &Store,
4989 head: &V2VerifiedHead,
4990 baseline: Option<&V2SyncBaseline>,
4991 carried: Option<V2LocalView>,
4992) -> LinkResult<V2LocalView> {
4993 match carried {
4994 Some(view) => Ok(view),
4999 None => {
5000 let mut view = v2_local_files(store)?;
5001 remove_scoped_projection(head, baseline, &mut view)?;
5002 Ok(view)
5003 }
5004 }
5005}
5006
5007fn files_for_v2_view(
5008 head: &V2VerifiedHead,
5009 mut files: std::collections::BTreeMap<String, V2BaselineFile>,
5010) -> std::collections::BTreeMap<String, V2BaselineFile> {
5011 if head.view_kind == "scoped" {
5012 files.remove("DB.md");
5016 }
5017 files
5018}
5019
5020fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
5021 let baseline: V2SyncBaseline =
5022 serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
5023 if baseline.v != 2
5024 || baseline.origin != normalized_origin(&cfg.hub)?
5025 || baseline.brain != brain
5026 || baseline
5027 .commit_hash
5028 .as_deref()
5029 .is_some_and(|hash| !is_sha256(hash))
5030 || baseline
5031 .content_root
5032 .as_deref()
5033 .is_some_and(|hash| !is_sha256(hash))
5034 || baseline
5035 .asset_root
5036 .as_deref()
5037 .is_some_and(|hash| !is_sha256(hash))
5038 || baseline
5039 .local_policy_digest
5040 .as_deref()
5041 .is_some_and(|hash| !is_sha256(hash))
5042 || baseline
5043 .view_kind
5044 .as_deref()
5045 .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
5046 || baseline
5047 .view_revision
5048 .as_deref()
5049 .is_some_and(|hash| !is_sha256(hash))
5050 || baseline
5051 .control_revision
5052 .as_deref()
5053 .is_some_and(|hash| !is_sha256(hash))
5054 || baseline
5055 .projection_sha256
5056 .as_deref()
5057 .is_some_and(|hash| !is_sha256(hash))
5058 || (baseline.view_kind.as_deref() == Some("scoped")
5059 && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
5060 || baseline.files.len() > MAX_PUSH_FILES
5061 || baseline.assets.len() > MAX_PUSH_FILES
5062 || baseline.local_eligibility.len() > MAX_PUSH_FILES
5063 || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
5064 || baseline.files.iter().any(|(path, file)| {
5065 crate::linkmd_v2::normalize_path(path).is_err()
5066 || !is_sha256(&file.sha256)
5067 || file.bytes > MAX_STORE_BYTES
5068 })
5069 || baseline.assets.iter().any(|(path, asset)| {
5070 crate::linkmd_v2::normalize_path(path).is_err()
5071 || !is_sha256(&asset.blob_sha256)
5072 || !is_sha256(&asset.leaf_hash)
5073 || asset.bytes > MAX_ASSET_BYTES
5074 || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
5075 || asset.wrappers.is_empty()
5076 || asset
5077 .wrappers
5078 .iter()
5079 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
5080 })
5081 || baseline
5082 .local_eligibility
5083 .keys()
5084 .chain(baseline.remote_copy_remains.keys())
5085 .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
5086 || baseline
5087 .remote_copy_remains
5088 .values()
5089 .any(|hash| !is_sha256(hash))
5090 || baseline
5091 .checkout_id
5092 .as_deref()
5093 .is_some_and(|checkout_id| !is_sha256(checkout_id))
5094 {
5095 return Err(invalid_feed("v2 sync baseline failed validation"));
5096 }
5097 Ok(baseline)
5098}
5099
5100#[cfg(unix)]
5101fn load_v2_baseline(
5102 cfg: &HubConfig,
5103 brain: &str,
5104 checkout: &Path,
5105) -> LinkResult<Option<V2SyncBaseline>> {
5106 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5107 let directory = open_trust_dir(cfg)?;
5108 let name_string = v2_baseline_name(cfg, brain, checkout)?;
5109 let _lock = lock_trust_name(&directory, &name_string)?;
5110 let name = c_name(name_string.as_bytes(), &name_string)?;
5111 let fd = unsafe {
5112 libc::openat(
5113 directory.as_raw_fd(),
5114 name.as_ptr(),
5115 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5116 )
5117 };
5118 if fd < 0 {
5119 let error = std::io::Error::last_os_error();
5120 return if error.kind() == std::io::ErrorKind::NotFound {
5121 Ok(None)
5122 } else {
5123 Err(LinkError::UnsafePath { path: name_string })
5124 };
5125 }
5126 let file = unsafe { std::fs::File::from_raw_fd(fd) };
5127 let mut bytes = Vec::new();
5128 file.take(MAX_FEED_RESPONSE_BYTES + 1)
5129 .read_to_end(&mut bytes)?;
5130 if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
5131 return Err(invalid_feed("v2 sync baseline is oversized"));
5132 }
5133 Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
5134}
5135
5136#[cfg(windows)]
5137fn load_v2_baseline(
5138 cfg: &HubConfig,
5139 brain: &str,
5140 checkout: &Path,
5141) -> LinkResult<Option<V2SyncBaseline>> {
5142 let directory = open_trust_dir(cfg)?;
5143 let name = v2_baseline_name(cfg, brain, checkout)?;
5144 let _lock = lock_trust_name(&directory, &name)?;
5145 let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
5146 match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
5147 Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5148 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5149 Err(_) => Err(LinkError::UnsafePath { path: name }),
5150 }
5151}
5152
5153#[cfg(not(any(unix, windows)))]
5154fn load_v2_baseline(
5155 _cfg: &HubConfig,
5156 _brain: &str,
5157 _checkout: &Path,
5158) -> LinkResult<Option<V2SyncBaseline>> {
5159 Err(LinkError::UnsupportedPlatform {
5160 operation: "verified link.md v2 baseline",
5161 })
5162}
5163
5164#[cfg(unix)]
5165fn save_v2_baseline(
5166 cfg: &HubConfig,
5167 brain: &str,
5168 checkout: &Path,
5169 baseline: &V2SyncBaseline,
5170) -> LinkResult<()> {
5171 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5172 let directory = open_trust_dir(cfg)?;
5173 let name_string = v2_baseline_name(cfg, brain, checkout)?;
5174 let _lock = lock_trust_name(&directory, &name_string)?;
5175 let name = c_name(name_string.as_bytes(), &name_string)?;
5176 let mut bytes = serde_json::to_vec(baseline)
5177 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5178 bytes.push(b'\n');
5179 let temp_string = format!(
5180 ".{name_string}.tmp.{}-{}",
5181 std::process::id(),
5182 std::time::SystemTime::now()
5183 .duration_since(std::time::UNIX_EPOCH)
5184 .unwrap_or_default()
5185 .as_nanos()
5186 );
5187 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5188 let fd = unsafe {
5189 libc::openat(
5190 directory.as_raw_fd(),
5191 temp.as_ptr(),
5192 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5193 0o600,
5194 )
5195 };
5196 if fd < 0 {
5197 return Err(std::io::Error::last_os_error().into());
5198 }
5199 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5200 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5201 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5202 return Err(error.into());
5203 }
5204 drop(file);
5205 if unsafe {
5206 libc::renameat(
5207 directory.as_raw_fd(),
5208 temp.as_ptr(),
5209 directory.as_raw_fd(),
5210 name.as_ptr(),
5211 )
5212 } != 0
5213 {
5214 let error = std::io::Error::last_os_error();
5215 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5216 return Err(error.into());
5217 }
5218 directory.sync_all()?;
5219 Ok(())
5220}
5221
5222#[cfg(windows)]
5223fn save_v2_baseline(
5224 cfg: &HubConfig,
5225 brain: &str,
5226 checkout: &Path,
5227 baseline: &V2SyncBaseline,
5228) -> LinkResult<()> {
5229 let directory = open_trust_dir(cfg)?;
5230 let name = v2_baseline_name(cfg, brain, checkout)?;
5231 let _lock = lock_trust_name(&directory, &name)?;
5232 let mut bytes = serde_json::to_vec(baseline)
5233 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5234 bytes.push(b'\n');
5235 crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5236 Ok(())
5237}
5238
5239#[cfg(not(any(unix, windows)))]
5240fn save_v2_baseline(
5241 _cfg: &HubConfig,
5242 _brain: &str,
5243 _checkout: &Path,
5244 _baseline: &V2SyncBaseline,
5245) -> LinkResult<()> {
5246 Err(LinkError::UnsupportedPlatform {
5247 operation: "verified link.md v2 baseline",
5248 })
5249}
5250
5251fn v2_baseline_from_head(
5252 cfg: &HubConfig,
5253 head: &V2VerifiedHead,
5254 files: std::collections::BTreeMap<String, V2BaselineFile>,
5255 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5256 local: Option<&V2LocalView>,
5257 checkout_id: Option<&str>,
5258) -> LinkResult<V2SyncBaseline> {
5259 let mut local_eligibility = local
5260 .map(|view| view.eligibility.clone())
5261 .unwrap_or_default();
5262 if let Some(view) = local {
5263 for path in files.keys() {
5264 local_eligibility
5265 .entry(path.clone())
5266 .or_insert_with(|| !view.policy.keeps_home(path));
5267 }
5268 }
5269 let remote_copy_remains = local_eligibility
5270 .iter()
5271 .filter(|(_, riding)| !**riding)
5272 .filter_map(|(path, _)| {
5273 files
5274 .get(path)
5275 .map(|file| (path.clone(), file.sha256.clone()))
5276 })
5277 .collect();
5278 Ok(V2SyncBaseline {
5279 v: 2,
5280 origin: normalized_origin(&cfg.hub)?,
5281 brain: head.brain_id.clone(),
5282 checkout_id: Some(v2_checkout_id(checkout_id)?),
5283 head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5284 commit_hash: head
5285 .pointer
5286 .as_ref()
5287 .map(|pointer| pointer.commit_hash.clone()),
5288 content_root: head
5289 .pointer
5290 .as_ref()
5291 .and_then(|pointer| pointer.content_root.clone()),
5292 asset_root: head
5293 .pointer
5294 .as_ref()
5295 .and_then(|pointer| pointer.asset_root.clone()),
5296 assets,
5297 view_kind: Some(head.view_kind.clone()),
5298 view_revision: Some(head.view_revision.clone()),
5299 control_revision: Some(head.control_revision.clone()),
5300 projection_sha256: (head.view_kind == "scoped")
5301 .then(|| scoped_projection_sha256(&head.brain_id)),
5302 files,
5303 local_policy_digest: local.map(|view| view.policy.digest.clone()),
5304 local_eligibility,
5305 remote_copy_remains,
5306 })
5307}
5308
5309fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5310 let policy = crate::linkmd_sync_policy::load(store)
5311 .map_err(|message| LinkError::InvalidPack { message })?;
5312 let asset_paths = crate::assets::read_manifest(store)
5313 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5314 .into_iter()
5315 .map(|asset| asset.path)
5316 .collect::<std::collections::BTreeSet<_>>();
5317 let mut result = std::collections::BTreeMap::new();
5318 let mut eligibility = std::collections::BTreeMap::new();
5319 let mut riding_links = Vec::<(String, Vec<String>)>::new();
5320 let mut total = 0_u64;
5321 let mut paths = vec![PathBuf::from("DB.md")];
5322 paths.extend(store.walk()?);
5323 for relative in paths {
5324 let path = relative.to_string_lossy().replace('\\', "/");
5325 if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5327 continue;
5328 }
5329 if asset_paths.contains(&path) {
5330 continue;
5331 }
5332 crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5333 path: error.to_string(),
5334 })?;
5335 let riding = !policy.keeps_home(&path);
5336 eligibility.insert(path.clone(), riding);
5337 if !riding {
5338 continue;
5339 }
5340 let remaining = MAX_STORE_BYTES.saturating_sub(total);
5341 let bytes = store.read_bounded(&relative, remaining)?;
5342 total = total
5343 .checked_add(bytes.len() as u64)
5344 .ok_or_else(|| LinkError::PushTooLarge {
5345 detail: "v2 local byte count overflow".to_string(),
5346 })?;
5347 if total > MAX_STORE_BYTES {
5348 return Err(LinkError::PushTooLarge {
5349 detail: format!("{total} uncompressed bytes"),
5350 });
5351 }
5352 if std::str::from_utf8(&bytes).is_err() {
5353 return Err(LinkError::NotUtf8 { path });
5354 }
5355 let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5356 riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
5357 result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
5358 }
5359 let kept_home = eligibility
5360 .iter()
5361 .filter(|(_, riding)| !**riding)
5362 .map(|(path, _)| path.clone())
5363 .collect::<std::collections::BTreeSet<_>>();
5364 let mut withheld_links = riding_links
5365 .into_iter()
5366 .flat_map(|(source, targets)| {
5367 let kept_home = &kept_home;
5368 let policy = &policy;
5369 targets.into_iter().filter_map(move |target| {
5370 let target = format!("{target}.md");
5371 (kept_home.contains(&target) || policy.keeps_home(&target)).then_some(
5381 V2WithheldLink {
5382 source: source.clone(),
5383 target,
5384 },
5385 )
5386 })
5387 })
5388 .collect::<Vec<_>>();
5389 withheld_links.sort();
5390 withheld_links.dedup();
5391 Ok(V2LocalView {
5392 riding: result,
5393 eligibility,
5394 policy,
5395 withheld_links,
5396 })
5397}
5398
5399#[derive(Debug, Clone, Deserialize)]
5400struct V2DownloadItem {
5401 path: String,
5402 sha256: String,
5403 bytes: u64,
5404 url: String,
5405 method: String,
5406}
5407
5408#[derive(Debug, Deserialize)]
5409struct V2DownloadWindow {
5410 v: u8,
5411 commit: String,
5412 downloads: Vec<V2DownloadItem>,
5413}
5414
5415#[derive(Debug, Deserialize)]
5416struct V2BulkStreamHeader {
5417 v: u8,
5418 path: String,
5419 sha256: String,
5420 bytes: u64,
5421}
5422
5423fn parse_v2_bulk_stream(
5424 bytes: &[u8],
5425 expected: &[(&String, &V2BaselineFile)],
5426) -> LinkResult<Vec<(String, Vec<u8>)>> {
5427 if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5428 return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5429 }
5430 let mut cursor = V2_BULK_STREAM_MAGIC.len();
5431 let mut result = Vec::with_capacity(expected.len());
5432 for (expected_path, expected_file) in expected {
5433 let length_bytes = bytes
5434 .get(cursor..cursor + 4)
5435 .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5436 cursor += 4;
5437 let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5438 if header_len == 0 || header_len > 4 * 1024 {
5439 return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5440 }
5441 let header_bytes = bytes
5442 .get(cursor..cursor + header_len)
5443 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5444 cursor += header_len;
5445 let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5446 .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5447 if header.v != 2
5448 || &header.path != *expected_path
5449 || header.sha256 != expected_file.sha256
5450 || header.bytes != expected_file.bytes
5451 || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5452 {
5453 return Err(invalid_feed(
5454 "v2 bulk stream frame differs from its proven manifest entry",
5455 ));
5456 }
5457 let body_len = usize::try_from(header.bytes)
5458 .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5459 let body = bytes
5460 .get(cursor..cursor + body_len)
5461 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5462 cursor += body_len;
5463 if content_sha256(body) != header.sha256 {
5464 return Err(invalid_feed(
5465 "v2 bulk stream file differs from its proven manifest entry",
5466 ));
5467 }
5468 result.push((header.path, body.to_vec()));
5469 }
5470 if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5471 return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5472 }
5473 cursor += 4;
5474 if cursor != bytes.len() {
5475 return Err(invalid_feed("v2 bulk stream carries trailing data"));
5476 }
5477 Ok(result)
5478}
5479
5480fn download_v2_bulk_stream(
5481 cfg: &HubConfig,
5482 brain: &str,
5483 pointer: &V2PointerBody,
5484 pending: &[(&String, &V2BaselineFile)],
5485) -> LinkResult<Vec<(String, Vec<u8>)>> {
5486 let claims = pending
5487 .iter()
5488 .map(|(path, file)| {
5489 Ok(json!({
5490 "path": path,
5491 "sha256": file.sha256,
5492 "bytes": file.bytes,
5493 "proof": file.proof.as_ref().ok_or_else(|| {
5494 invalid_feed("v2 manifest omitted a bulk-stream proof")
5495 })?,
5496 }))
5497 })
5498 .collect::<LinkResult<Vec<_>>>()?;
5499 let raw = request_raw_retryable_read(
5500 cfg,
5501 "POST",
5502 &format!("/api/hub/brains/{brain}/v2/stream"),
5503 Some(&json!({
5504 "commit": pointer.commit_hash,
5505 "files": claims,
5506 })),
5507 Auth::Required,
5508 V2_BULK_STREAM_RESPONSE_BYTES,
5509 )?;
5510 let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5511 parse_v2_bulk_stream(&body, pending)
5512}
5513
5514fn request_capped_retryable_read(
5515 cfg: &HubConfig,
5516 method: &str,
5517 path: &str,
5518 body: Option<&Value>,
5519 auth: Auth,
5520 max_response_bytes: u64,
5521) -> LinkResult<HubResponse> {
5522 let raw = request_raw_retryable_read(cfg, method, path, body, auth, max_response_bytes)?;
5523 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
5524 Ok(HubResponse {
5525 status: raw.status,
5526 body: parsed,
5527 })
5528}
5529
5530fn prepare_v2_downloads(
5531 cfg: &HubConfig,
5532 brain: &str,
5533 pointer: &V2PointerBody,
5534 pending: &[(&String, &V2BaselineFile)],
5535) -> LinkResult<Vec<V2DownloadItem>> {
5536 let mut result = Vec::with_capacity(pending.len());
5537 for chunk in pending.chunks(128) {
5538 let claims = chunk
5539 .iter()
5540 .map(|(path, file)| {
5541 Ok(json!({
5542 "path": path,
5543 "sha256": file.sha256,
5544 "bytes": file.bytes,
5545 "proof": file.proof.as_ref().ok_or_else(|| {
5546 invalid_feed("v2 manifest omitted a download proof")
5547 })?,
5548 }))
5549 })
5550 .collect::<LinkResult<Vec<_>>>()?;
5551 let value = ensure_ok(
5552 request_capped_retryable_read(
5553 cfg,
5554 "POST",
5555 &format!("/api/hub/brains/{brain}/v2/downloads"),
5556 Some(&json!({
5557 "commit": pointer.commit_hash,
5558 "files": claims,
5559 })),
5560 Auth::Required,
5561 MAX_FEED_RESPONSE_BYTES,
5562 )?,
5563 "prepare v2 blob downloads",
5564 )?;
5565 let window: V2DownloadWindow = serde_json::from_value(value)
5566 .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5567 if window.v != 2
5568 || window.commit != pointer.commit_hash
5569 || window.downloads.len() != chunk.len()
5570 {
5571 return Err(invalid_feed(
5572 "v2 download window is not bound to the requested files",
5573 ));
5574 }
5575 let mut by_path = window
5576 .downloads
5577 .into_iter()
5578 .map(|item| (item.path.clone(), item))
5579 .collect::<std::collections::BTreeMap<_, _>>();
5580 if by_path.len() != chunk.len() {
5581 return Err(invalid_feed("v2 download window repeats a path"));
5582 }
5583 for (path, file) in chunk {
5584 let item = by_path
5585 .remove(*path)
5586 .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5587 if item.method != "GET"
5588 || item.sha256 != file.sha256
5589 || item.bytes != file.bytes
5590 || item.url.is_empty()
5591 {
5592 return Err(invalid_feed(
5593 "v2 download capability differs from its proven file",
5594 ));
5595 }
5596 result.push(item);
5597 }
5598 }
5599 Ok(result)
5600}
5601
5602fn prepare_v2_asset_downloads(
5603 cfg: &HubConfig,
5604 brain: &str,
5605 pointer: &V2PointerBody,
5606 pending: &[(&String, &V2BaselineAsset)],
5607) -> LinkResult<Vec<V2DownloadItem>> {
5608 let mut result = Vec::with_capacity(pending.len());
5609 for chunk in pending.chunks(128) {
5610 let claims = chunk
5611 .iter()
5612 .map(|(path, asset)| {
5613 json!({
5614 "path": path,
5615 "sha256": asset.blob_sha256,
5616 "bytes": asset.bytes,
5617 "leaf_hash": asset.leaf_hash,
5618 })
5619 })
5620 .collect::<Vec<_>>();
5621 let value = ensure_ok(
5622 request_capped_retryable_read(
5623 cfg,
5624 "POST",
5625 &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5626 Some(&json!({
5627 "commit": pointer.commit_hash,
5628 "assets": claims,
5629 })),
5630 Auth::Required,
5631 MAX_FEED_RESPONSE_BYTES,
5632 )?,
5633 "prepare v2 asset downloads",
5634 )?;
5635 let window: V2DownloadWindow = serde_json::from_value(value)
5636 .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5637 if window.v != 2
5638 || window.commit != pointer.commit_hash
5639 || window.downloads.len() != chunk.len()
5640 {
5641 return Err(invalid_feed(
5642 "v2 asset download window is not bound to the requested assets",
5643 ));
5644 }
5645 let mut by_path = window
5646 .downloads
5647 .into_iter()
5648 .map(|item| (item.path.clone(), item))
5649 .collect::<std::collections::BTreeMap<_, _>>();
5650 if by_path.len() != chunk.len() {
5651 return Err(invalid_feed("v2 asset download window repeats a path"));
5652 }
5653 for (path, asset) in chunk {
5654 let item = by_path
5655 .remove(*path)
5656 .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5657 if item.method != "GET"
5658 || item.sha256 != asset.blob_sha256
5659 || item.bytes != asset.bytes
5660 || item.url.is_empty()
5661 {
5662 return Err(invalid_feed(
5663 "v2 asset download capability differs from its signed leaf",
5664 ));
5665 }
5666 result.push(item);
5667 }
5668 }
5669 Ok(result)
5670}
5671
5672#[cfg(any(unix, windows))]
5673fn stage_v2_asset_download_window(
5674 cfg: &HubConfig,
5675 brain: &str,
5676 pointer: &V2PointerBody,
5677 cache_dir: &Path,
5678 pending: &[(&String, &V2BaselineAsset)],
5679) -> LinkResult<Vec<V2StagedFile>> {
5680 if pending.is_empty() {
5681 return Ok(Vec::new());
5682 }
5683 if pending.len() > V2_DOWNLOAD_CAPABILITY_FILES {
5684 return Err(invalid_feed("v2 asset capability window is oversized"));
5685 }
5686
5687 let mut last_error = None;
5688 for retry_delay in V2_DOWNLOAD_CAPABILITY_BACKOFF_MS
5689 .iter()
5690 .copied()
5691 .map(Some)
5692 .chain(std::iter::once(None))
5693 {
5694 let downloads = prepare_v2_asset_downloads(cfg, brain, pointer, pending)?;
5699 let mut unique = std::collections::BTreeMap::<String, V2DownloadItem>::new();
5700 for item in downloads {
5701 match unique.get(&item.sha256) {
5702 Some(prior) if prior.bytes != item.bytes => {
5703 return Err(invalid_feed(
5704 "one v2 asset hash has conflicting byte lengths",
5705 ));
5706 }
5707 Some(_) => {}
5708 None => {
5709 unique.insert(item.sha256.clone(), item);
5710 }
5711 }
5712 }
5713 let downloads = unique.into_values().collect::<Vec<_>>();
5714 let next = std::sync::atomic::AtomicUsize::new(0);
5715 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5716 let mut results = std::iter::repeat_with(|| None)
5717 .take(downloads.len())
5718 .collect::<Vec<Option<LinkResult<PathBuf>>>>();
5719 std::thread::scope(|scope| {
5720 let (sender, receiver) = std::sync::mpsc::channel();
5721 for _ in 0..worker_count {
5722 let sender = sender.clone();
5723 let downloads = &downloads;
5724 let next = &next;
5725 scope.spawn(move || loop {
5726 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5727 let Some(item) = downloads.get(index) else {
5728 break;
5729 };
5730 let result = download_presigned_to_cache(
5731 cfg,
5732 &item.url,
5733 cache_dir,
5734 &item.sha256,
5735 item.bytes,
5736 );
5737 if sender.send((index, result)).is_err() {
5738 break;
5739 }
5740 });
5741 }
5742 drop(sender);
5743 for (index, result) in receiver {
5744 results[index] = Some(result);
5745 }
5746 });
5747
5748 let mut failed = None;
5749 for result in results {
5750 match result {
5751 Some(Ok(_)) => {}
5752 Some(Err(error)) if failed.is_none() => failed = Some(error),
5753 Some(Err(_)) => {}
5754 None if failed.is_none() => {
5755 failed = Some(LinkError::Transport {
5756 hub: cfg.hub.clone(),
5757 message: "a bounded v2 asset worker stopped before reporting its result"
5758 .to_string(),
5759 });
5760 }
5761 None => {}
5762 }
5763 }
5764 if let Some(error) = failed {
5765 last_error = Some(error);
5766 if let Some(milliseconds) = retry_delay {
5767 std::thread::sleep(std::time::Duration::from_millis(milliseconds));
5768 continue;
5769 }
5770 break;
5771 }
5772
5773 return pending
5774 .iter()
5775 .map(|(path, asset)| {
5776 let source = cache_dir.join(&asset.blob_sha256);
5777 if !cached_blob_is_exact(&source, &asset.blob_sha256, asset.bytes)? {
5778 return Err(invalid_feed(
5779 "v2 asset download cache omitted a proven blob",
5780 ));
5781 }
5782 Ok(V2StagedFile {
5783 path: (*path).clone(),
5784 source,
5785 sha256: asset.blob_sha256.clone(),
5786 bytes: asset.bytes,
5787 })
5788 })
5789 .collect();
5790 }
5791 Err(last_error
5792 .unwrap_or_else(|| invalid_feed("v2 asset capability window made no download attempt")))
5793}
5794
5795fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5796 let bytes = get_presigned(cfg, &item.url)?;
5797 if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5798 return Err(invalid_feed("v2 blob differs from its proven path entry"));
5799 }
5800 Ok(bytes)
5801}
5802
5803#[derive(Debug, Clone)]
5804struct V2StagedFile {
5805 path: String,
5806 source: PathBuf,
5807 sha256: String,
5808 bytes: u64,
5809}
5810
5811#[cfg(unix)]
5812fn v2_download_cache_dir(
5813 cfg: &HubConfig,
5814 brain: &str,
5815 pointer: &V2PointerBody,
5816) -> LinkResult<PathBuf> {
5817 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5818}
5819
5820#[cfg(unix)]
5821fn v2_download_cache_dir_for(
5822 cfg: &HubConfig,
5823 brain: &str,
5824 transaction: &str,
5825) -> LinkResult<PathBuf> {
5826 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5827 return Err(invalid_feed("v2 download cache address is invalid"));
5828 }
5829 let path = cfg
5830 .state_dir
5831 .join("downloads")
5832 .join(brain)
5833 .join(transaction);
5834 let directory = open_or_create_dir_nofollow(&path)?;
5835 use std::os::fd::AsRawFd as _;
5836 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5837 return Err(std::io::Error::last_os_error().into());
5838 }
5839 directory.sync_all()?;
5840 Ok(path)
5841}
5842
5843#[cfg(windows)]
5844fn v2_download_cache_dir(
5845 cfg: &HubConfig,
5846 brain: &str,
5847 pointer: &V2PointerBody,
5848) -> LinkResult<PathBuf> {
5849 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5850}
5851
5852#[cfg(windows)]
5853fn v2_download_cache_dir_for(
5854 cfg: &HubConfig,
5855 brain: &str,
5856 transaction: &str,
5857) -> LinkResult<PathBuf> {
5858 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5859 return Err(invalid_feed("v2 download cache address is invalid"));
5860 }
5861 let path = cfg
5862 .state_dir
5863 .join("downloads")
5864 .join(brain)
5865 .join(transaction);
5866 crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5867 crate::fsx::open_directory_nofollow(&path)?;
5868 Ok(path)
5869}
5870
5871#[cfg(unix)]
5872fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5873 use std::os::fd::AsRawFd as _;
5874 let parent = cfg.state_dir.join("downloads").join(brain);
5875 let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5876 return;
5877 };
5878 let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5879 return;
5880 };
5881 let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5882 let _ = directory.sync_all();
5883}
5884
5885#[cfg(windows)]
5886fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5887 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5888 return;
5889 }
5890 let parent = cfg.state_dir.join("downloads").join(brain);
5891 let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5892 return;
5893 };
5894 let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5895}
5896
5897#[cfg(not(any(unix, windows)))]
5898fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5899
5900#[cfg(not(any(unix, windows)))]
5901fn v2_download_cache_dir_for(
5902 _cfg: &HubConfig,
5903 _brain: &str,
5904 _transaction: &str,
5905) -> LinkResult<PathBuf> {
5906 Err(LinkError::UnsupportedPlatform {
5907 operation: "resumable v2 download staging",
5908 })
5909}
5910
5911#[cfg(any(unix, windows))]
5912fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5913 let file = match crate::fsx::open_regular_nofollow(path) {
5914 Ok(file) => file,
5915 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5916 Err(error) => return Err(error.into()),
5917 };
5918 if file.metadata()?.len() != bytes {
5919 return Ok(false);
5920 }
5921 Ok(content_sha256_reader(file)? == sha256)
5922}
5923
5924#[cfg(any(unix, windows))]
5925fn cache_v2_blob_bytes(
5926 cache_dir: &Path,
5927 sha256: &str,
5928 expected_bytes: u64,
5929 bytes: &[u8],
5930) -> LinkResult<PathBuf> {
5931 if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5932 return Err(invalid_feed("v2 cached blob differs from its declaration"));
5933 }
5934 let path = cache_dir.join(sha256);
5935 if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5936 crate::fsx::write_atomic(&path, bytes)?;
5937 }
5938 Ok(path)
5939}
5940
5941#[cfg(not(any(unix, windows)))]
5942fn cache_v2_blob_bytes(
5943 _cache_dir: &Path,
5944 _sha256: &str,
5945 _expected_bytes: u64,
5946 _bytes: &[u8],
5947) -> LinkResult<PathBuf> {
5948 Err(LinkError::UnsupportedPlatform {
5949 operation: "resumable v2 download staging",
5950 })
5951}
5952
5953#[cfg(unix)]
5954fn download_presigned_to_cache(
5955 cfg: &HubConfig,
5956 url: &str,
5957 cache_dir: &Path,
5958 sha256: &str,
5959 expected_bytes: u64,
5960) -> LinkResult<PathBuf> {
5961 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5962
5963 let target = cache_dir.join(sha256);
5964 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5965 return Ok(target);
5966 }
5967 let directory = open_existing_dir_nofollow(cache_dir)?;
5968 let mut nonce = [0_u8; 16];
5969 ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5970 .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5971 let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5972 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5973 let fd = unsafe {
5974 libc::openat(
5975 directory.as_raw_fd(),
5976 temp.as_ptr(),
5977 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5978 0o600,
5979 )
5980 };
5981 if fd < 0 {
5982 return Err(std::io::Error::last_os_error().into());
5983 }
5984 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5985 let response = match presigned_agent(cfg, url)?.get(url).call() {
5986 Ok(response) => response,
5987 Err(ureq::Error::Status(_, response)) => {
5988 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5989 return Err(LinkError::Http {
5990 what: "v2 direct download",
5991 status: response.status(),
5992 message: "object store rejected the download".to_string(),
5993 code: None,
5994 details: None,
5995 });
5996 }
5997 Err(ureq::Error::Transport(error)) => {
5998 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5999 return Err(LinkError::Transport {
6000 hub: cfg.hub.clone(),
6001 message: error.to_string(),
6002 });
6003 }
6004 };
6005 let mut reader = response
6006 .into_reader()
6007 .take(expected_bytes.saturating_add(1));
6008 let mut digest = Sha256::new();
6009 let mut total = 0_u64;
6010 let mut buffer = [0_u8; 64 * 1024];
6011 let write_result = (|| -> LinkResult<()> {
6016 loop {
6017 let read = reader
6018 .read(&mut buffer)
6019 .map_err(|error| LinkError::Transport {
6020 hub: cfg.hub.clone(),
6021 message: error.to_string(),
6022 })?;
6023 if read == 0 {
6024 break;
6025 }
6026 total = total.saturating_add(read as u64);
6027 digest.update(&buffer[..read]);
6028 output.write_all(&buffer[..read])?;
6029 }
6030 output.sync_all().map_err(LinkError::from)
6031 })();
6032 if let Err(error) = write_result {
6033 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6034 return Err(error);
6035 }
6036 drop(output);
6037 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6038 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6039 return Err(invalid_feed(
6040 "v2 direct download failed integrity verification",
6041 ));
6042 }
6043 let target_name = c_name(sha256.as_bytes(), sha256)?;
6044 if unsafe {
6047 libc::renameat(
6048 directory.as_raw_fd(),
6049 temp.as_ptr(),
6050 directory.as_raw_fd(),
6051 target_name.as_ptr(),
6052 )
6053 } != 0
6054 {
6055 let error = std::io::Error::last_os_error();
6056 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6057 return Err(error.into());
6058 }
6059 directory.sync_all()?;
6060 Ok(target)
6061}
6062
6063#[cfg(windows)]
6064fn download_presigned_to_cache(
6065 cfg: &HubConfig,
6066 url: &str,
6067 cache_dir: &Path,
6068 sha256: &str,
6069 expected_bytes: u64,
6070) -> LinkResult<PathBuf> {
6071 use std::fs::OpenOptions;
6072
6073 let target = cache_dir.join(sha256);
6074 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6075 return Ok(target);
6076 }
6077 let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
6081 let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
6082 let mut output = OpenOptions::new()
6083 .write(true)
6084 .create_new(true)
6085 .open(&temp)?;
6086 let response = match presigned_agent(cfg, url)?.get(url).call() {
6087 Ok(response) => response,
6088 Err(ureq::Error::Status(_, response)) => {
6089 let _ = std::fs::remove_file(&temp);
6090 return Err(LinkError::Http {
6091 what: "v2 direct download",
6092 status: response.status(),
6093 message: "object store rejected the download".to_string(),
6094 code: None,
6095 details: None,
6096 });
6097 }
6098 Err(ureq::Error::Transport(error)) => {
6099 let _ = std::fs::remove_file(&temp);
6100 return Err(LinkError::Transport {
6101 hub: cfg.hub.clone(),
6102 message: error.to_string(),
6103 });
6104 }
6105 };
6106 let mut reader = response
6107 .into_reader()
6108 .take(expected_bytes.saturating_add(1));
6109 let mut digest = Sha256::new();
6110 let mut total = 0_u64;
6111 let mut buffer = [0_u8; 64 * 1024];
6112 let copied = (|| -> LinkResult<()> {
6114 loop {
6115 let read = reader
6116 .read(&mut buffer)
6117 .map_err(|error| LinkError::Transport {
6118 hub: cfg.hub.clone(),
6119 message: error.to_string(),
6120 })?;
6121 if read == 0 {
6122 break;
6123 }
6124 total = total.saturating_add(read as u64);
6125 digest.update(&buffer[..read]);
6126 output.write_all(&buffer[..read])?;
6127 }
6128 output.sync_all()?;
6129 Ok(())
6130 })();
6131 if let Err(error) = copied {
6132 let _ = std::fs::remove_file(&temp);
6133 return Err(error);
6134 }
6135 drop(output);
6136 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6137 let _ = std::fs::remove_file(&temp);
6138 return Err(invalid_feed(
6139 "v2 direct download failed integrity verification",
6140 ));
6141 }
6142 if target.exists() {
6143 std::fs::remove_file(&target)?;
6144 }
6145 if let Err(error) = std::fs::rename(&temp, &target) {
6146 let _ = std::fs::remove_file(&temp);
6147 return Err(error.into());
6148 }
6149 Ok(target)
6150}
6151
6152#[cfg(not(any(unix, windows)))]
6153fn download_presigned_to_cache(
6154 _cfg: &HubConfig,
6155 _url: &str,
6156 _cache_dir: &Path,
6157 _sha256: &str,
6158 _expected_bytes: u64,
6159) -> LinkResult<PathBuf> {
6160 Err(LinkError::UnsupportedPlatform {
6161 operation: "resumable v2 download staging",
6162 })
6163}
6164
6165fn download_v2_blobs(
6166 cfg: &HubConfig,
6167 brain: &str,
6168 pointer: &V2PointerBody,
6169 pending: Vec<(&String, &V2BaselineFile)>,
6170) -> LinkResult<Vec<(String, Vec<u8>)>> {
6171 if pending.is_empty() {
6172 return Ok(Vec::new());
6173 }
6174 let expected_order = pending
6175 .iter()
6176 .map(|(path, _)| (*path).clone())
6177 .collect::<Vec<_>>();
6178 let mut streamed = std::collections::BTreeMap::new();
6179 let mut direct = Vec::new();
6180 let mut window = Vec::new();
6181 let mut window_bytes = 0_u64;
6182 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6183 window_bytes: &mut u64,
6184 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
6185 -> LinkResult<()> {
6186 if window.is_empty() {
6187 return Ok(());
6188 }
6189 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
6190 if streamed.insert(path, bytes).is_some() {
6191 return Err(invalid_feed("v2 bulk streams repeated a path"));
6192 }
6193 }
6194 window.clear();
6195 *window_bytes = 0;
6196 Ok(())
6197 };
6198 for &(path, file) in &pending {
6199 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6200 flush(&mut window, &mut window_bytes, &mut streamed)?;
6201 direct.push((path, file));
6202 continue;
6203 }
6204 if window.len() == V2_BULK_STREAM_FILES
6205 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6206 {
6207 flush(&mut window, &mut window_bytes, &mut streamed)?;
6208 }
6209 window.push((path, file));
6210 window_bytes += file.bytes;
6211 }
6212 flush(&mut window, &mut window_bytes, &mut streamed)?;
6213
6214 let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
6215 let next = std::sync::atomic::AtomicUsize::new(0);
6216 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6217 let mut results = std::iter::repeat_with(|| None)
6218 .take(downloads.len())
6219 .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
6220 std::thread::scope(|scope| {
6221 let (sender, receiver) = std::sync::mpsc::channel();
6222 for _ in 0..worker_count {
6223 let sender = sender.clone();
6224 let downloads = &downloads;
6225 let next = &next;
6226 scope.spawn(move || loop {
6227 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6228 let Some(item) = downloads.get(index) else {
6229 break;
6230 };
6231 let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
6232 if sender.send((index, result)).is_err() {
6233 break;
6234 }
6235 });
6236 }
6237 drop(sender);
6238 for (index, result) in receiver {
6239 results[index] = Some(result);
6240 }
6241 });
6242 for result in results.into_iter().map(|result| {
6243 result.ok_or_else(|| LinkError::Transport {
6244 hub: cfg.hub.clone(),
6245 message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
6246 })?
6247 }) {
6248 let (path, bytes) = result?;
6249 if streamed.insert(path, bytes).is_some() {
6250 return Err(invalid_feed("v2 download lanes repeated a path"));
6251 }
6252 }
6253 expected_order
6254 .into_iter()
6255 .map(|path| {
6256 streamed
6257 .remove(&path)
6258 .map(|bytes| (path, bytes))
6259 .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
6260 })
6261 .collect()
6262}
6263
6264#[cfg(any(unix, windows))]
6268fn stage_v2_blobs(
6269 cfg: &HubConfig,
6270 brain: &str,
6271 pointer: &V2PointerBody,
6272 pending: Vec<(&String, &V2BaselineFile)>,
6273) -> LinkResult<Vec<V2StagedFile>> {
6274 let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6275 let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6276 let mut direct = Vec::new();
6277 let mut window = Vec::new();
6278 let mut window_bytes = 0_u64;
6279 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6280 window_bytes: &mut u64,
6281 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
6282 -> LinkResult<()> {
6283 if window.is_empty() {
6284 return Ok(());
6285 }
6286 let missing = window
6287 .iter()
6288 .filter_map(|(path, file)| {
6289 let target = cache_dir.join(&file.sha256);
6290 match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6291 Ok(true) => {
6292 staged.insert(
6293 (*path).clone(),
6294 V2StagedFile {
6295 path: (*path).clone(),
6296 source: target,
6297 sha256: file.sha256.clone(),
6298 bytes: file.bytes,
6299 },
6300 );
6301 None
6302 }
6303 Ok(false) => Some(Ok((*path, *file))),
6304 Err(error) => Some(Err(error)),
6305 }
6306 })
6307 .collect::<LinkResult<Vec<_>>>()?;
6308 if !missing.is_empty() {
6309 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
6310 let file = missing
6311 .iter()
6312 .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
6313 .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
6314 let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
6315 staged.insert(
6316 path.clone(),
6317 V2StagedFile {
6318 path,
6319 source,
6320 sha256: file.sha256.clone(),
6321 bytes: file.bytes,
6322 },
6323 );
6324 }
6325 }
6326 window.clear();
6327 *window_bytes = 0;
6328 Ok(())
6329 };
6330 for &(path, file) in &pending {
6331 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6332 flush(&mut window, &mut window_bytes, &mut staged)?;
6333 direct.push((path, file));
6334 continue;
6335 }
6336 if window.len() == V2_BULK_STREAM_FILES
6337 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6338 {
6339 flush(&mut window, &mut window_bytes, &mut staged)?;
6340 }
6341 window.push((path, file));
6342 window_bytes += file.bytes;
6343 }
6344 flush(&mut window, &mut window_bytes, &mut staged)?;
6345 for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6346 let source =
6347 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6348 staged.insert(
6349 item.path.clone(),
6350 V2StagedFile {
6351 path: item.path,
6352 source,
6353 sha256: item.sha256,
6354 bytes: item.bytes,
6355 },
6356 );
6357 }
6358 pending
6359 .into_iter()
6360 .map(|(path, _)| {
6361 staged
6362 .remove(path)
6363 .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6364 })
6365 .collect()
6366}
6367
6368#[cfg(not(any(unix, windows)))]
6369fn stage_v2_blobs(
6370 _cfg: &HubConfig,
6371 _brain: &str,
6372 _pointer: &V2PointerBody,
6373 _pending: Vec<(&String, &V2BaselineFile)>,
6374) -> LinkResult<Vec<V2StagedFile>> {
6375 Err(LinkError::UnsupportedPlatform {
6376 operation: "resumable v2 download staging",
6377 })
6378}
6379
6380const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6381const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6382const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6383
6384#[derive(Debug, Clone, Deserialize, Serialize)]
6385struct V2ConflictCoordinate {
6386 sha256: Option<String>,
6387 bytes: Option<u64>,
6388 file: Option<String>,
6389}
6390
6391#[derive(Debug, Clone, Deserialize, Serialize)]
6392struct V2ConflictFile {
6393 path: String,
6394 base: V2ConflictCoordinate,
6395 local: V2ConflictCoordinate,
6396 remote: V2ConflictCoordinate,
6397}
6398
6399#[derive(Debug, Clone, Deserialize, Serialize)]
6400struct V2ConflictPlan {
6401 v: u8,
6402 class: String,
6403 bundle: String,
6404 brain: String,
6405 origin: String,
6406 created_unix: u64,
6407 expires_unix: u64,
6408 base_seq: Option<u64>,
6409 base_commit: Option<String>,
6410 remote_seq: u64,
6411 remote_commit: Option<String>,
6412 remote_content_root: Option<String>,
6413 view_kind: String,
6414 view_revision: String,
6415 files: Vec<V2ConflictFile>,
6416}
6417
6418fn v2_take_remote_selection(
6419 files: &[V2ConflictFile],
6420 current: &std::collections::BTreeMap<String, V2BaselineFile>,
6421) -> LinkResult<(
6422 std::collections::BTreeMap<String, V2BaselineFile>,
6423 Vec<String>,
6424)> {
6425 let mut selected = std::collections::BTreeMap::new();
6426 let mut deleted = Vec::new();
6427 for file in files {
6428 match (&file.remote.sha256, file.remote.bytes) {
6429 (Some(sha256), Some(bytes)) => {
6430 let proven = current.get(&file.path).ok_or_else(|| {
6431 invalid_feed("conflict remote coordinate disappeared from the exact head")
6432 })?;
6433 if proven.sha256 != *sha256 || proven.bytes != bytes {
6434 return Err(invalid_feed(
6435 "conflict remote coordinate differs from the exact head",
6436 ));
6437 }
6438 if selected.insert(file.path.clone(), proven.clone()).is_some() {
6439 return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6440 }
6441 }
6442 (None, None) => {
6443 if current.contains_key(&file.path) {
6444 return Err(invalid_feed(
6445 "conflict remote deletion differs from the exact head",
6446 ));
6447 }
6448 deleted.push(file.path.clone());
6449 }
6450 _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6451 }
6452 }
6453 Ok((selected, deleted))
6454}
6455
6456fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6457 PathBuf::from(".dbmd")
6458 .join("conflicts")
6459 .join(bundle)
6460 .join(suffix)
6461}
6462
6463fn read_historical_conflict_blob(
6464 cfg: &HubConfig,
6465 brain: &str,
6466 baseline: &V2SyncBaseline,
6467 path: &str,
6468 file: &V2BaselineFile,
6469) -> LinkResult<Option<Vec<u8>>> {
6470 let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6471 return Ok(None);
6472 };
6473 if seq == 0 {
6474 return Ok(None);
6475 }
6476 let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6477 let endpoint = format!(
6478 "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6479 file.sha256
6480 );
6481 let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
6482 if raw.status == 404 || raw.status == 403 {
6483 return Ok(None);
6484 }
6485 let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6486 if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6487 return Err(invalid_feed(
6488 "v2 conflict base failed integrity verification",
6489 ));
6490 }
6491 Ok(Some(bytes))
6492}
6493
6494fn create_v2_conflict_bundle(
6497 cfg: &HubConfig,
6498 store: &Store,
6499 head: &V2VerifiedHead,
6500 baseline: Option<&V2SyncBaseline>,
6501 local: &std::collections::BTreeMap<String, (String, u64)>,
6502 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6503 paths: &[String],
6504) -> LinkResult<(String, Vec<String>)> {
6505 let conflicts_root = Path::new(".dbmd/conflicts");
6506 store.create_dir_all(conflicts_root)?;
6507 let completed = store
6508 .directory_names(conflicts_root)?
6509 .into_iter()
6510 .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6511 .count();
6512 if completed >= V2_CONFLICT_BUNDLE_MAX {
6513 return Err(LinkError::InvalidPack {
6514 message: format!(
6515 "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6516 ),
6517 });
6518 }
6519
6520 let mut selected_paths = Vec::new();
6524 let mut selected_remote_bytes = 0_u64;
6525 for path in paths {
6526 let bytes = remote.get(path).map_or(0, |file| file.bytes);
6527 if !selected_paths.is_empty()
6528 && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6529 {
6530 break;
6531 }
6532 selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6533 selected_paths.push(path.clone());
6534 if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6535 break;
6536 }
6537 }
6538 if selected_paths.is_empty() {
6539 return Err(invalid_feed("content conflict set is empty"));
6540 }
6541 let bundle = crate::ulid::mint();
6542 let bundle_root = v2_conflict_relative(&bundle, "");
6543 store.create_dir_all(&bundle_root.join("files"))?;
6544 let pointer = head.pointer.as_ref();
6545 let remote_bytes = match pointer {
6546 Some(pointer) => download_v2_blobs(
6547 cfg,
6548 &head.brain_id,
6549 pointer,
6550 selected_paths
6551 .iter()
6552 .filter_map(|path| {
6553 remote
6554 .get(path)
6555 .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6556 .map(|file| (path, file))
6557 })
6558 .collect(),
6559 )?
6560 .into_iter()
6561 .collect::<std::collections::BTreeMap<_, _>>(),
6562 None => std::collections::BTreeMap::new(),
6563 };
6564
6565 let mut files = Vec::with_capacity(selected_paths.len());
6566 for (index, path) in selected_paths.iter().enumerate() {
6567 let base_file = baseline.and_then(|state| state.files.get(path));
6568 let base_bytes = match (baseline, base_file) {
6569 (Some(state), Some(file)) => {
6570 read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
6571 }
6572 _ => None,
6573 };
6574 let local_file = local.get(path);
6575 let remote_file = remote.get(path);
6576 let remote_content = remote_bytes.get(path);
6577 let prefix = format!("files/{index:04}");
6578 let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6579 let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6580 let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6581 if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6582 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6583 }
6584 if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6585 let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6586 if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6587 return Err(LinkError::InvalidPack {
6588 message: format!("local conflict path `{path}` changed while bundling"),
6589 });
6590 }
6591 store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6592 }
6593 if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6594 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6595 }
6596 files.push(V2ConflictFile {
6597 path: path.clone(),
6598 base: V2ConflictCoordinate {
6599 sha256: base_file.map(|file| file.sha256.clone()),
6600 bytes: base_file.map(|file| file.bytes),
6601 file: base_name,
6602 },
6603 local: V2ConflictCoordinate {
6604 sha256: local_file.map(|(sha256, _)| sha256.clone()),
6605 bytes: local_file.map(|(_, bytes)| *bytes),
6606 file: local_name,
6607 },
6608 remote: V2ConflictCoordinate {
6609 sha256: remote_file.map(|file| file.sha256.clone()),
6610 bytes: remote_file.map(|file| file.bytes),
6611 file: remote_name,
6612 },
6613 });
6614 }
6615 let now = SystemTime::now()
6616 .duration_since(UNIX_EPOCH)
6617 .unwrap_or_default()
6618 .as_secs();
6619 let plan = V2ConflictPlan {
6620 v: 2,
6621 class: "content_resolution_required".to_string(),
6622 bundle: bundle.clone(),
6623 brain: head.brain_id.clone(),
6624 origin: normalized_origin(&cfg.hub)?,
6625 created_unix: now,
6626 expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6627 base_seq: baseline.and_then(|state| state.head_seq),
6628 base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6629 remote_seq: pointer.map_or(0, |value| value.seq),
6630 remote_commit: pointer.map(|value| value.commit_hash.clone()),
6631 remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6632 view_kind: head.view_kind.clone(),
6633 view_revision: head.view_revision.clone(),
6634 files,
6635 };
6636 let mut bytes = serde_json::to_vec_pretty(&plan)
6637 .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6638 bytes.push(b'\n');
6639 store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6640 Ok((bundle, selected_paths))
6641}
6642
6643fn v2_sync_pull_with_resolution(
6644 cfg: &HubConfig,
6645 requested_brain: &str,
6646 expected_head: V2VerifiedHead,
6647 out: Option<&Path>,
6648 take_remote: Option<&std::collections::BTreeSet<String>>,
6649) -> LinkResult<V2PulledSnapshot> {
6650 let dest = out
6651 .map(Path::to_path_buf)
6652 .unwrap_or_else(|| PathBuf::from(requested_brain));
6653 let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6654 recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6655 let head = v2_verified_head(cfg, requested_brain)?
6656 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6657 if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6658 return Err(LinkError::RemoteAdvancedDuringSync);
6659 }
6660 let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6661 ensure_v2_view_compatible(&head, baseline.as_ref())?;
6662 let (remote, remote_assets) = match baseline
6663 .as_ref()
6664 .filter(|state| v2_baseline_matches_head(&head, state))
6665 {
6666 Some(state) => (state.files.clone(), state.assets.clone()),
6667 None => (
6668 files_for_v2_view(
6669 &head,
6670 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6671 ),
6672 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6673 ),
6674 };
6675 let local_store = Store::open_strict(&dest).ok();
6676 ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6681 let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6682 if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6683 return Err(LinkError::ScopedViewChanged);
6684 }
6685 if let Some(view) = local_view.as_mut() {
6686 remove_scoped_projection(&head, baseline.as_ref(), view)?;
6687 }
6688 let empty_local = std::collections::BTreeMap::new();
6689 let local = local_view
6690 .as_ref()
6691 .map_or(&empty_local, |view| &view.riding);
6692 let kept_home = |path: &str| {
6693 local_view
6694 .as_ref()
6695 .is_some_and(|view| view.policy.keeps_home(path))
6696 };
6697 let empty_base = std::collections::BTreeMap::new();
6698 let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6699 let empty_base_assets = std::collections::BTreeMap::new();
6700 let base_assets = baseline
6701 .as_ref()
6702 .map_or(&empty_base_assets, |state| &state.assets);
6703 let mut local_assets = local_store
6704 .as_ref()
6705 .map(v2_local_asset_records)
6706 .transpose()?
6707 .unwrap_or_default();
6708 let mut content_merge = merge_v2_pulled_records(
6709 base,
6710 &remote,
6711 local,
6712 |file, _| (file.sha256.clone(), file.bytes),
6713 |file, _| (file.sha256.clone(), file.bytes),
6714 kept_home,
6715 );
6716 if let Some(selected) = take_remote {
6717 for path in selected {
6718 if let Some(position) = content_merge
6719 .conflicts
6720 .iter()
6721 .position(|conflict| conflict == path)
6722 {
6723 content_merge.conflicts.remove(position);
6724 content_merge.accept_remote.insert(path.clone());
6725 match remote.get(path) {
6726 Some(file) => {
6727 content_merge
6728 .records
6729 .insert(path.clone(), (file.sha256.clone(), file.bytes));
6730 }
6731 None => {
6732 content_merge.records.remove(path);
6733 }
6734 }
6735 } else if !content_merge.accept_remote.contains(path) {
6736 return Err(LinkError::InvalidPack {
6737 message: format!(
6738 "take-remote path `{path}` is no longer at its conflict coordinate"
6739 ),
6740 });
6741 }
6742 }
6743 }
6744 if !content_merge.conflicts.is_empty() {
6745 let mut conflicts = content_merge.conflicts.clone();
6746 conflicts.truncate(100);
6747 if let Some(store) = local_store.as_ref() {
6748 let (bundle, paths) = create_v2_conflict_bundle(
6749 cfg,
6750 store,
6751 &head,
6752 baseline.as_ref(),
6753 local,
6754 &remote,
6755 &conflicts,
6756 )?;
6757 return Err(LinkError::ConflictBundle { bundle, paths });
6758 }
6759 return Err(LinkError::Conflict { paths: conflicts });
6760 }
6761 let asset_merge = merge_v2_pulled_records(
6762 base_assets,
6763 &remote_assets,
6764 &local_assets,
6765 v2_asset_record,
6766 v2_asset_record,
6767 |_| false,
6768 );
6769 if !asset_merge.conflicts.is_empty() {
6770 let mut conflicts = asset_merge.conflicts.clone();
6771 conflicts.truncate(100);
6772 return Err(LinkError::Conflict { paths: conflicts });
6773 }
6774 let pointer = head.pointer.as_ref();
6775 let cache_transaction = pointer.map_or_else(
6776 || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6777 |value| value.commit_hash.clone(),
6778 );
6779 let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6780 let mut changed = match pointer {
6781 Some(pointer) => stage_v2_blobs(
6782 cfg,
6783 &head.brain_id,
6784 pointer,
6785 remote
6786 .iter()
6787 .filter(|(path, file)| {
6788 content_merge.accept_remote.contains(*path)
6789 && local.get(*path).map(|value| value.0.as_str())
6790 != Some(file.sha256.as_str())
6791 })
6792 .collect(),
6793 )?,
6794 None => Vec::new(),
6795 };
6796 let mut deleted = content_merge
6797 .accept_remote
6798 .iter()
6799 .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6800 .cloned()
6801 .collect::<Vec<_>>();
6802 if local_assets != asset_merge.records {
6803 if asset_merge.records.is_empty() {
6804 deleted.push("assets.jsonl".to_string());
6805 } else {
6806 let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6807 let sha256 = content_sha256(&bytes);
6808 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6809 changed.push(V2StagedFile {
6810 path: "assets.jsonl".to_string(),
6811 source,
6812 sha256,
6813 bytes: bytes.len() as u64,
6814 });
6815 }
6816 }
6817 if let Some(pointer) = pointer {
6818 let mut pending_assets = Vec::new();
6819 for (path, asset) in &remote_assets {
6820 if asset.disposition != "hosted"
6821 || kept_home(path)
6822 || !asset_merge.accept_remote.contains(path)
6823 {
6824 continue;
6825 }
6826 let already_current = local_store.as_ref().is_some_and(|store| {
6827 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6828 && store
6829 .read_bounded(Path::new(path), asset.bytes)
6830 .ok()
6831 .is_some_and(|bytes| {
6832 bytes.len() as u64 == asset.bytes
6833 && content_sha256(&bytes) == asset.blob_sha256
6834 })
6835 });
6836 if !already_current {
6837 pending_assets.push((path, asset));
6838 }
6839 }
6840 let mut window = Vec::new();
6841 let mut window_bytes = 0_u64;
6842 let flush = |window: &mut Vec<(&String, &V2BaselineAsset)>,
6843 window_bytes: &mut u64,
6844 changed: &mut Vec<V2StagedFile>|
6845 -> LinkResult<()> {
6846 changed.extend(stage_v2_asset_download_window(
6847 cfg,
6848 &head.brain_id,
6849 pointer,
6850 &cache_dir,
6851 window,
6852 )?);
6853 window.clear();
6854 *window_bytes = 0;
6855 Ok(())
6856 };
6857 for item @ (_, asset) in pending_assets {
6858 if !window.is_empty()
6859 && (window.len() == V2_DOWNLOAD_CAPABILITY_FILES
6860 || window_bytes.saturating_add(asset.bytes) > V2_DOWNLOAD_CAPABILITY_BYTES)
6861 {
6862 flush(&mut window, &mut window_bytes, &mut changed)?;
6863 }
6864 window.push(item);
6865 window_bytes = window_bytes.saturating_add(asset.bytes);
6866 if window_bytes >= V2_DOWNLOAD_CAPABILITY_BYTES {
6867 flush(&mut window, &mut window_bytes, &mut changed)?;
6868 }
6869 }
6870 flush(&mut window, &mut window_bytes, &mut changed)?;
6871 }
6872 for (path, prior) in base_assets {
6873 if remote_assets.contains_key(path)
6874 || kept_home(path)
6875 || !asset_merge.accept_remote.contains(path)
6876 {
6877 continue;
6878 }
6879 let unchanged = local_store.as_ref().is_some_and(|store| {
6880 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6881 && store
6882 .read_bounded(Path::new(path), prior.bytes)
6883 .ok()
6884 .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6885 });
6886 if unchanged {
6887 deleted.push(path.clone());
6888 }
6889 }
6890 let extra_local = content_merge
6891 .records
6892 .keys()
6893 .filter(|path| !remote.contains_key(*path))
6894 .cloned()
6895 .collect::<Vec<_>>();
6896 if head.view_kind == "scoped" {
6897 for (path, bytes) in [
6898 ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6899 (
6900 ".dbmd/view.json".to_string(),
6901 scoped_view_metadata(&head, remote.len())?,
6902 ),
6903 ] {
6904 let sha256 = content_sha256(&bytes);
6905 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6906 changed.push(V2StagedFile {
6907 path,
6908 source,
6909 sha256,
6910 bytes: bytes.len() as u64,
6911 });
6912 }
6913 }
6914 let install_changed = !changed.is_empty() || !deleted.is_empty();
6915 install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6916 let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6917 let installed_store =
6918 Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6919 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6920 })?;
6921 let installed_local = if install_changed {
6922 let mut scanned = v2_local_files(&installed_store)?;
6923 remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6924 scanned
6925 } else {
6926 local_view
6927 .take()
6928 .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6929 };
6930 if installed_local.riding != content_merge.records {
6931 return Err(LinkError::InvalidPack {
6932 message: "local content changed while installing the v2 pull".to_string(),
6933 });
6934 }
6935 let installed_assets = if install_changed {
6936 v2_local_asset_records(&installed_store)?
6937 } else {
6938 std::mem::take(&mut local_assets)
6939 };
6940 if installed_assets != asset_merge.records {
6941 return Err(LinkError::InvalidPack {
6942 message: "local assets changed while installing the v2 pull".to_string(),
6943 });
6944 }
6945 let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6946 installed_local.policy.keeps_home(path)
6947 })
6948 || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6949 let final_head = v2_verified_head(cfg, requested_brain)?
6950 .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6951 if !same_v2_head(&head, &final_head) {
6952 return Err(LinkError::RemoteAdvancedDuringSync);
6953 }
6954 accept_v2_head(cfg, &final_head)?;
6955 save_v2_baseline(
6956 cfg,
6957 &head.brain_id,
6958 &dest,
6959 &v2_baseline_from_head(
6960 cfg,
6961 &head,
6962 remote.clone(),
6963 remote_assets.clone(),
6964 Some(&installed_local),
6965 baseline
6966 .as_ref()
6967 .and_then(|current| current.checkout_id.as_deref()),
6968 )?,
6969 )?;
6970 complete_v2_pull(&dest)?;
6971 Ok((local_dirty, installed_local, installed_assets))
6972 })();
6973 let (local_dirty, installed_local, installed_assets) = match finalized {
6974 Ok(value) => value,
6975 Err(error) => {
6976 if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6977 return Err(LinkError::InvalidPack {
6978 message: format!("{error}; durable pull recovery also failed: {recovery}"),
6979 });
6980 }
6981 return Err(error);
6982 }
6983 };
6984 cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6985 let report = PullReport {
6986 brain: head.brain_id.clone(),
6987 slug: requested_brain.to_string(),
6988 head_seq: pointer.map_or(0, |value| value.seq),
6989 files: remote.len() + remote_assets.len(),
6990 dest: dest.to_string_lossy().into_owned(),
6991 extra_local,
6992 sync_status: if local_dirty {
6993 "local_dirty_after_install".to_string()
6994 } else {
6995 "synced".to_string()
6996 },
6997 };
6998 Ok(V2PulledSnapshot {
6999 report,
7000 head,
7001 files: remote,
7002 assets: remote_assets,
7003 local: installed_local,
7004 local_assets: installed_assets,
7005 })
7006}
7007
7008fn v2_sync_pull(
7009 cfg: &HubConfig,
7010 requested_brain: &str,
7011 head: V2VerifiedHead,
7012 out: Option<&Path>,
7013) -> LinkResult<PullReport> {
7014 Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
7015}
7016
7017fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
7018 match remote {
7019 Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
7020 None => json!({ "kind": "absent" }),
7021 }
7022}
7023
7024fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
7025 match remote {
7026 Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
7027 None => json!({ "kind": "absent" }),
7028 }
7029}
7030
7031fn v2_content_withdrawal_operation(
7032 store: &Store,
7033 local_view: &V2LocalView,
7034 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7035 path: &str,
7036 reason: &str,
7037) -> LinkResult<Value> {
7038 if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
7039 || path == "DB.md"
7040 {
7041 return Err(LinkError::InvalidPack {
7042 message: format!("content withdrawal path `{path}` is not a record or source"),
7043 });
7044 }
7045 if !local_view.policy.keeps_home(path)
7046 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7047 {
7048 return Err(LinkError::InvalidPack {
7049 message: format!(
7050 "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7051 ),
7052 });
7053 }
7054 let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
7055 message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
7056 })?;
7057 verify_v2_upload_source(store, path, ¤t.sha256, current.bytes)?;
7058 Ok(json!({
7059 "op": "withdraw_from_hosting",
7060 "path": path,
7061 "expected": { "kind": "blob", "hash": current.sha256 },
7062 "reason": reason,
7063 }))
7064}
7065
7066fn v2_asset_withdrawal_operation(
7067 store: &Store,
7068 local_view: &V2LocalView,
7069 path: &str,
7070 local: &crate::AssetRecord,
7071 current: &V2BaselineAsset,
7072 reason: &str,
7073) -> LinkResult<Value> {
7074 if !local_view.policy.keeps_home(path)
7075 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7076 {
7077 return Err(LinkError::InvalidPack {
7078 message: format!(
7079 "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7080 ),
7081 });
7082 }
7083 verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
7084 if current.disposition != "hosted"
7085 || current.blob_sha256 != local.sha256
7086 || current.bytes != local.bytes
7087 || current.media_type != local.media_type
7088 {
7089 return Err(LinkError::InvalidPack {
7090 message: format!(
7091 "asset withdrawal path `{path}` must preserve the currently hosted blob identity, byte count, and media type"
7092 ),
7093 });
7094 }
7095 Ok(json!({
7096 "op": "asset_withdraw",
7097 "path": path,
7098 "expected": v2_asset_expected(Some(current)),
7099 "asset": v2_asset_value(local, "withheld"),
7100 "reason": reason,
7101 }))
7102}
7103
7104fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
7111 let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
7112 let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
7113 for (index, operation) in operations.iter().enumerate() {
7114 match operation.get("op").and_then(Value::as_str) {
7115 Some("delete") => {
7116 let Some(path) = operation.get("path").and_then(Value::as_str) else {
7117 continue;
7118 };
7119 let Some(hash) = operation
7120 .get("expected")
7121 .and_then(|value| value.get("hash"))
7122 .and_then(Value::as_str)
7123 else {
7124 continue;
7125 };
7126 if path.starts_with("sources/") {
7127 deletes
7128 .entry(hash.to_string())
7129 .or_default()
7130 .push((index, path.to_string()));
7131 }
7132 }
7133 Some("put") => {
7134 let Some(path) = operation.get("path").and_then(Value::as_str) else {
7135 continue;
7136 };
7137 let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
7138 continue;
7139 };
7140 let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
7141 continue;
7142 };
7143 let destination_absent = operation
7144 .get("expected")
7145 .and_then(|value| value.get("kind"))
7146 .and_then(Value::as_str)
7147 == Some("absent");
7148 if path.starts_with("sources/") && destination_absent {
7149 puts.entry(hash.to_string()).or_default().push((
7150 index,
7151 path.to_string(),
7152 bytes,
7153 ));
7154 }
7155 }
7156 _ => {}
7157 }
7158 }
7159 let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
7160 let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
7161 for (hash, source) in deletes {
7162 let Some(destination) = puts.get(&hash) else {
7163 continue;
7164 };
7165 if source.len() != 1 || destination.len() != 1 {
7166 continue;
7167 }
7168 let (delete_index, from) = &source[0];
7169 let (put_index, to, bytes) = &destination[0];
7170 if from == to {
7171 continue;
7172 }
7173 rename_at.insert(
7174 *delete_index,
7175 json!({
7176 "op": "rename",
7177 "from": from,
7178 "to": to,
7179 "expected_from": { "kind": "blob", "hash": hash },
7180 "expected_to": { "kind": "absent" },
7181 "blob": hash,
7182 "bytes": bytes,
7183 }),
7184 );
7185 consumed_puts.insert(*put_index);
7186 }
7187 operations
7188 .into_iter()
7189 .enumerate()
7190 .filter_map(|(index, operation)| {
7191 if let Some(rename) = rename_at.remove(&index) {
7192 Some(rename)
7193 } else if consumed_puts.contains(&index) {
7194 None
7195 } else {
7196 Some(operation)
7197 }
7198 })
7199 .collect()
7200}
7201
7202fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
7203 json!({
7204 "blob_sha256": record.sha256,
7205 "bytes": record.bytes,
7206 "media_type": record.media_type,
7207 "wrappers": record.wrappers,
7208 "required": record.required,
7209 "disposition": disposition,
7210 })
7211}
7212
7213fn apply_generated_v2_operations(
7217 operations: &[Value],
7218 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
7219 candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
7220 candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
7221) -> LinkResult<bool> {
7222 let mut asset_changed = false;
7223 for operation in operations {
7224 match operation.get("op").and_then(Value::as_str) {
7225 Some("put") => {
7226 let path = operation
7227 .get("path")
7228 .and_then(Value::as_str)
7229 .ok_or_else(|| invalid_feed("v2 put has no path"))?;
7230 let sha256 = operation
7231 .get("blob")
7232 .and_then(Value::as_str)
7233 .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
7234 let bytes = operation
7235 .get("bytes")
7236 .and_then(Value::as_u64)
7237 .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
7238 candidate.insert(
7239 path.to_string(),
7240 V2BaselineFile {
7241 sha256: sha256.to_string(),
7242 bytes,
7243 proof: None,
7244 },
7245 );
7246 }
7247 Some("rename") => {
7248 let from = operation
7249 .get("from")
7250 .and_then(Value::as_str)
7251 .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
7252 let to = operation
7253 .get("to")
7254 .and_then(Value::as_str)
7255 .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
7256 let sha256 = operation
7257 .get("blob")
7258 .and_then(Value::as_str)
7259 .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
7260 let bytes = operation
7261 .get("bytes")
7262 .and_then(Value::as_u64)
7263 .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
7264 let expected_from = operation
7265 .get("expected_from")
7266 .and_then(|expected| expected.get("hash"))
7267 .and_then(Value::as_str);
7268 let expected_to_absent = operation
7269 .get("expected_to")
7270 .and_then(|expected| expected.get("kind"))
7271 .and_then(Value::as_str)
7272 == Some("absent");
7273 if from == to
7274 || !from.starts_with("sources/")
7275 || !to.starts_with("sources/")
7276 || expected_from != Some(sha256)
7277 || !expected_to_absent
7278 || candidate.contains_key(to)
7279 {
7280 return Err(invalid_feed("generated v2 source rename is malformed"));
7281 }
7282 let source = candidate
7283 .remove(from)
7284 .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
7285 if source.sha256 != sha256 || source.bytes != bytes {
7286 return Err(invalid_feed(
7287 "v2 rename source differs from its exact-byte claim",
7288 ));
7289 }
7290 candidate.insert(
7291 to.to_string(),
7292 V2BaselineFile {
7293 sha256: sha256.to_string(),
7294 bytes,
7295 proof: None,
7296 },
7297 );
7298 }
7299 Some("delete" | "withdraw_from_hosting") => {
7300 let path = operation
7301 .get("path")
7302 .and_then(Value::as_str)
7303 .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7304 candidate.remove(path);
7305 }
7306 Some("asset_delete") => {
7307 let path = operation
7308 .get("path")
7309 .and_then(Value::as_str)
7310 .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7311 candidate_assets.remove(path);
7312 asset_changed = true;
7313 }
7314 Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7315 let path = operation
7316 .get("path")
7317 .and_then(Value::as_str)
7318 .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7319 let record = local_assets
7320 .get(path)
7321 .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7322 let disposition =
7323 if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7324 "withheld"
7325 } else {
7326 operation
7327 .get("asset")
7328 .and_then(|asset| asset.get("disposition"))
7329 .and_then(Value::as_str)
7330 .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7331 };
7332 candidate_assets.insert(
7333 path.to_string(),
7334 V2BaselineAsset {
7335 blob_sha256: record.sha256.clone(),
7336 bytes: record.bytes,
7337 media_type: record.media_type.clone(),
7338 wrappers: record.wrappers.clone(),
7339 required: record.required,
7340 disposition: disposition.to_string(),
7341 leaf_hash: String::new(),
7344 },
7345 );
7346 asset_changed = true;
7347 }
7348 _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7349 }
7350 }
7351 Ok(asset_changed)
7352}
7353
7354fn v2_riding_matches_remote(
7355 local: &std::collections::BTreeMap<String, (String, u64)>,
7356 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7357 keeps_home: impl Fn(&str) -> bool,
7358) -> bool {
7359 remote.iter().all(|(path, file)| {
7360 keeps_home(path)
7361 || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7362 }) && local.iter().all(|(path, (hash, _))| {
7363 remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7364 })
7365}
7366
7367fn v2_initial_content_conflicts(
7368 local: &std::collections::BTreeMap<String, (String, u64)>,
7369 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7370 resolving: bool,
7371) -> Vec<String> {
7372 if resolving {
7373 return Vec::new();
7381 }
7382 remote
7383 .iter()
7384 .filter(|(path, file)| {
7385 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7386 })
7387 .map(|(path, _)| path.clone())
7388 .collect()
7389}
7390
7391fn v2_resolution_allows_path(
7392 resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7393 path: &str,
7394 remote_present: bool,
7395) -> bool {
7396 resolution.is_none_or(|allowed| allowed.contains_key(path) || !remote_present)
7397}
7398
7399#[derive(Debug, Clone)]
7400struct V2ResolutionOverride {
7401 expected_remote: Option<String>,
7402 selected_local: Option<String>,
7403}
7404
7405#[derive(Debug, Clone)]
7406struct V2UploadSource {
7407 path: String,
7408 bytes: u64,
7409}
7410
7411struct V2SyncPushOptions<'a> {
7412 resume_local_policy: bool,
7413 bulk_confirmation: Option<&'a V2BulkConfirmation>,
7414 resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7415 pulled: Option<V2PulledSnapshot>,
7416 withdrawal_paths: &'a [String],
7417 withdrawal_reason: Option<&'a str>,
7418}
7419
7420fn verify_v2_upload_source(
7421 store: &Store,
7422 path: &str,
7423 sha256: &str,
7424 expected_bytes: u64,
7425) -> LinkResult<()> {
7426 let file = store.open_regular(Path::new(path))?;
7427 if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7428 return Err(LinkError::InvalidPack {
7429 message: format!("local path `{path}` changed during sync planning"),
7430 });
7431 }
7432 Ok(())
7433}
7434
7435struct V2PendingUpload<'a> {
7438 url: String,
7439 headers: Value,
7440 sha256: String,
7441 source: &'a V2UploadSource,
7442}
7443
7444const V2_UPLOAD_CONCURRENCY: usize = 16;
7451
7452fn upload_v2_batch_concurrently(
7456 cfg: &HubConfig,
7457 store: &Store,
7458 pending: &[V2PendingUpload<'_>],
7459) -> LinkResult<()> {
7460 if pending.is_empty() {
7461 return Ok(());
7462 }
7463 let urls = pending
7464 .iter()
7465 .map(|task| task.url.as_str())
7466 .collect::<Vec<_>>();
7467 let shared = shared_staging_agent(cfg, &urls);
7468 if pending.len() == 1 {
7469 let task = &pending[0];
7470 put_presigned_source(
7471 cfg,
7472 &task.url,
7473 &task.headers,
7474 store,
7475 task.source,
7476 shared.as_ref(),
7477 )?;
7478 return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7479 }
7480 let next = std::sync::atomic::AtomicUsize::new(0);
7481 let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7482 let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7483 std::thread::scope(|scope| {
7484 for _ in 0..workers {
7485 scope.spawn(|| loop {
7486 if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7487 return;
7488 }
7489 let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7490 let Some(task) = pending.get(index) else {
7491 return;
7492 };
7493 let outcome = put_presigned_source(
7494 cfg,
7495 &task.url,
7496 &task.headers,
7497 store,
7498 task.source,
7499 shared.as_ref(),
7500 )
7501 .and_then(|()| {
7502 verify_v2_upload_source(
7503 store,
7504 &task.source.path,
7505 &task.sha256,
7506 task.source.bytes,
7507 )
7508 });
7509 if let Err(error) = outcome {
7510 if let Ok(mut guard) = failure.lock() {
7511 guard.get_or_insert(error);
7512 }
7513 return;
7514 }
7515 });
7516 }
7517 });
7518 match failure.into_inner() {
7519 Ok(Some(error)) => Err(error),
7520 Ok(None) => Ok(()),
7521 Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7522 }
7523}
7524
7525fn put_presigned_source(
7526 cfg: &HubConfig,
7527 raw: &str,
7528 headers: &Value,
7529 store: &Store,
7530 source: &V2UploadSource,
7531 shared: Option<&ureq::Agent>,
7532) -> LinkResult<()> {
7533 put_presigned_source_with_budget(
7534 cfg,
7535 raw,
7536 headers,
7537 store,
7538 source,
7539 shared,
7540 std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
7541 )
7542}
7543
7544fn put_presigned_source_with_budget(
7545 cfg: &HubConfig,
7546 raw: &str,
7547 headers: &Value,
7548 store: &Store,
7549 source: &V2UploadSource,
7550 shared: Option<&ureq::Agent>,
7551 total_budget: std::time::Duration,
7552) -> LinkResult<()> {
7553 let owned = match shared {
7556 Some(_) => {
7557 checked_presigned_url(cfg, raw)?;
7558 None
7559 }
7560 None => Some(presigned_agent(cfg, raw)?),
7561 };
7562 let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7563 let deadline = std::time::Instant::now()
7564 .checked_add(total_budget)
7565 .ok_or_else(upload_deadline_error)?;
7566 let mut attempt = 0;
7567 let result = loop {
7568 let file = store.open_regular(Path::new(&source.path))?;
7569 if file.metadata()?.len() != source.bytes {
7570 return Err(LinkError::InvalidPack {
7571 message: format!("local path `{}` changed before upload", source.path),
7572 });
7573 }
7574 let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
7579 let mut has_content_length = false;
7580 if let Some(map) = headers.as_object() {
7581 for (name, value) in map {
7582 if let Some(value) = value.as_str() {
7583 has_content_length |= name.eq_ignore_ascii_case("content-length");
7584 req = req.set(name, value);
7585 }
7586 }
7587 }
7588 if !has_content_length {
7589 req = req.set("Content-Length", &source.bytes.to_string());
7590 }
7591 match req.send(file) {
7592 Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
7598 attempt += 1;
7599 }
7600 Err(ureq::Error::Status(status, _))
7606 if status != 412
7607 && is_retryable_upload_status(status)
7608 && wait_for_upload_retry(deadline, attempt) =>
7609 {
7610 attempt += 1;
7611 }
7612 result => break result,
7613 }
7614 };
7615 match result {
7616 Ok(response) if (200..300).contains(&response.status()) => {
7617 drain_presigned_response(response);
7618 Ok(())
7619 }
7620 Ok(response) => {
7621 let status = response.status();
7626 let detail = response
7627 .into_string()
7628 .ok()
7629 .map(|body| body.chars().take(400).collect::<String>())
7630 .filter(|body| !body.trim().is_empty());
7631 Err(LinkError::Http {
7632 what: "v2 changed-byte upload",
7633 status,
7634 message: match detail {
7635 Some(body) => format!(
7636 "object store rejected the upload of `{}`: {}",
7637 source.path,
7638 body.replace('\n', " ")
7639 ),
7640 None => format!("object store rejected the upload of `{}`", source.path),
7641 },
7642 code: None,
7643 details: None,
7644 })
7645 }
7646 Err(error) => match error {
7647 ureq::Error::Status(412, _) => Ok(()),
7648 ureq::Error::Status(_, response) => {
7649 let status = response.status();
7650 let detail = response
7651 .into_string()
7652 .ok()
7653 .map(|body| body.chars().take(400).collect::<String>())
7654 .filter(|body| !body.trim().is_empty());
7655 Err(LinkError::Http {
7656 what: "v2 changed-byte upload",
7657 status,
7658 message: match detail {
7659 Some(body) => format!(
7660 "object store rejected the upload of `{}`: {}",
7661 source.path,
7662 body.replace('\n', " ")
7663 ),
7664 None => {
7665 format!("object store rejected the upload of `{}`", source.path)
7666 }
7667 },
7668 code: None,
7669 details: None,
7670 })
7671 }
7672 ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
7673 },
7674 }
7675}
7676
7677fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
7681 if body.get("operations").is_some() {
7682 return body.clone();
7683 }
7684 let mut value = body.clone();
7685 if let Some(map) = value.as_object_mut() {
7686 map.remove("staged_change");
7687 map.insert("operations".to_string(), Value::Array(operations.to_vec()));
7688 }
7689 value
7690}
7691
7692fn reserve_upload_window(
7696 cfg: &HubConfig,
7697 path: &str,
7698 body: &Value,
7699 what: &'static str,
7700) -> LinkResult<Value> {
7701 let mut attempt = 0;
7702 loop {
7703 let pause = |attempt: usize| {
7704 std::thread::sleep(std::time::Duration::from_millis(
7705 RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
7706 ));
7707 };
7708 match request(cfg, "POST", path, Some(body), Auth::Required) {
7709 Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
7714 pause(attempt);
7715 attempt += 1;
7716 }
7717 Err(error) => return Err(error),
7718 Ok(response) => {
7719 if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
7720 pause(attempt);
7721 attempt += 1;
7722 continue;
7723 }
7724 return ensure_ok(response, what);
7725 }
7726 }
7727 }
7728}
7729
7730fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
7734 let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
7735 .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
7736 if bytes.len() > MAX_STAGED_CHANGE_BYTES {
7737 return Err(LinkError::PushTooLarge {
7738 detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
7739 });
7740 }
7741 Ok(bytes)
7742}
7743
7744fn stage_v2_change(
7754 cfg: &HubConfig,
7755 requested_brain: &str,
7756 operations: &[Value],
7757 blobs: Value,
7758) -> LinkResult<Value> {
7759 let bytes = v2_change_manifest(operations, blobs)?;
7760 let sha256 = content_sha256(&bytes);
7761 let reserved = reserve_upload_window(
7762 cfg,
7763 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7764 &json!({
7765 "blobs": [{
7766 "sha256": sha256,
7767 "bytes": bytes.len(),
7768 "kind": "staged_change",
7769 }],
7770 }),
7771 "stage the v2 change",
7772 )?;
7773 let items = reserved
7774 .get("uploads")
7775 .and_then(Value::as_array)
7776 .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
7777 let [item] = items.as_slice() else {
7778 return Err(invalid_feed(
7779 "v2 change staging response changed the requested set",
7780 ));
7781 };
7782 let reservation_id = item
7783 .get("reservation_id")
7784 .and_then(Value::as_str)
7785 .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
7786 if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
7787 || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
7788 || !crate::ulid::is_ulid(reservation_id)
7789 {
7790 return Err(invalid_feed("v2 change staging item is inconsistent"));
7791 }
7792 match item.get("status").and_then(Value::as_str) {
7793 Some("upload") => put_presigned(
7794 cfg,
7795 item.get("url")
7796 .and_then(Value::as_str)
7797 .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
7798 item.get("headers").unwrap_or(&Value::Null),
7799 &bytes,
7800 )?,
7801 Some("already_present") => {}
7802 _ => return Err(invalid_feed("v2 change staging has an unknown status")),
7803 }
7804 Ok(json!({
7805 "sha256": sha256,
7806 "bytes": bytes.len(),
7807 "reservation_id": reservation_id,
7808 }))
7809}
7810
7811fn stage_oversized_v2_change(
7815 cfg: &HubConfig,
7816 requested_brain: &str,
7817 operations: &[Value],
7818 body: &mut Value,
7819) -> LinkResult<()> {
7820 if body.to_string().len() <= MAX_PUSH_BYTES {
7821 return Ok(());
7822 }
7823 let staged = stage_v2_change(
7824 cfg,
7825 requested_brain,
7826 operations,
7827 body.get("blobs")
7828 .cloned()
7829 .unwrap_or(Value::Array(Vec::new())),
7830 )?;
7831 let map = body
7832 .as_object_mut()
7833 .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
7834 map.remove("operations");
7835 map.remove("blobs");
7836 map.insert("staged_change".to_string(), staged);
7837 Ok(())
7838}
7839
7840fn v2_sync_push(
7841 cfg: &HubConfig,
7842 requested_brain: &str,
7843 store: &Store,
7844 head: V2VerifiedHead,
7845 options: V2SyncPushOptions<'_>,
7846) -> LinkResult<Value> {
7847 let V2SyncPushOptions {
7848 resume_local_policy,
7849 bulk_confirmation,
7850 resolution,
7851 pulled,
7852 withdrawal_paths,
7853 withdrawal_reason,
7854 } = options;
7855 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
7856 let head = v2_verified_head(cfg, requested_brain)?
7857 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7858 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
7859 ensure_v2_view_compatible(&head, baseline.as_ref())?;
7860 let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
7861 match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
7862 Some(snapshot) => (
7863 snapshot.files,
7864 snapshot.assets,
7865 Some(snapshot.local),
7866 Some(snapshot.local_assets),
7867 ),
7868 None => match baseline
7869 .as_ref()
7870 .filter(|state| v2_baseline_matches_head(&head, state))
7871 {
7872 Some(state) => (state.files.clone(), state.assets.clone(), None, None),
7873 None => (
7874 files_for_v2_view(
7875 &head,
7876 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7877 ),
7878 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7879 None,
7880 None,
7881 ),
7882 },
7883 };
7884 if head.view_kind == "scoped" && baseline.is_none() {
7885 return Err(LinkError::ScopedViewChanged);
7886 }
7887 let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
7888 let local = &local_view.riding;
7889 let local_assets = match carried_local_assets {
7890 Some(assets) => assets,
7891 None => v2_local_asset_records(store)?,
7892 };
7893 if withdrawal_paths.len() > MAX_PUSH_FILES {
7894 return Err(LinkError::PushTooLarge {
7895 detail: "too many explicit withdrawal paths".to_string(),
7896 });
7897 }
7898 let withdrawal_reason = if withdrawal_paths.is_empty() {
7899 None
7900 } else {
7901 let reason = withdrawal_reason
7902 .map(str::trim)
7903 .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
7904 .ok_or_else(|| LinkError::InvalidPack {
7905 message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
7906 })?;
7907 Some(reason)
7908 };
7909 let mut withdrawals = withdrawal_paths
7910 .iter()
7911 .map(|path| {
7912 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
7913 path: error.to_string(),
7914 })
7915 })
7916 .collect::<LinkResult<Vec<_>>>()?;
7917 withdrawals.sort();
7918 withdrawals.dedup();
7919 if withdrawals.len() != withdrawal_paths.len() {
7920 return Err(LinkError::InvalidPack {
7921 message: "explicit withdrawal paths must be unique".to_string(),
7922 });
7923 }
7924 let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
7925 let mut consumed_withdrawals = BTreeSet::new();
7926 if let Some(previous) = baseline.as_ref() {
7927 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
7928 && !resume_local_policy
7929 {
7930 let mut newly_eligible = previous
7931 .local_eligibility
7932 .iter()
7933 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7934 .map(|(path, _)| path.clone())
7935 .collect::<Vec<_>>();
7936 if !newly_eligible.is_empty() {
7937 newly_eligible.truncate(100);
7938 return Err(LinkError::LocalPolicyTransition {
7939 paths: newly_eligible,
7940 });
7941 }
7942 }
7943 }
7944 let base = match baseline.as_ref() {
7945 Some(state) => &state.files,
7946 None if remote.is_empty() => &remote,
7947 None => {
7948 let mut conflicts = v2_initial_content_conflicts(local, &remote, resolution.is_some());
7949 if !conflicts.is_empty() {
7950 conflicts.truncate(100);
7951 let (bundle, paths) =
7952 create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7953 return Err(LinkError::ConflictBundle { bundle, paths });
7954 }
7955 &remote
7956 }
7957 };
7958 let all_paths = base
7959 .keys()
7960 .chain(remote.keys())
7961 .chain(local.keys())
7962 .cloned()
7963 .collect::<std::collections::BTreeSet<_>>();
7964 let mut conflicts = Vec::new();
7965 let mut operations = Vec::new();
7966 let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7967 for path in all_paths {
7968 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7969 let remote_file = remote.get(&path);
7970 let remote_hash = remote_file.map(|file| file.sha256.as_str());
7971 let local_file = local.get(&path);
7972 let local_hash = local_file.map(|file| file.0.as_str());
7973 if local_hash == base_hash {
7974 continue;
7975 }
7976 if !v2_resolution_allows_path(resolution, &path, remote_file.is_some()) {
7977 continue;
7978 }
7979 if local_view.policy.keeps_home(&path) {
7980 continue;
7983 }
7984 if remote_hash != base_hash && local_hash != remote_hash {
7985 let explicitly_resolved = resolution
7986 .and_then(|allowed| allowed.get(&path))
7987 .is_some_and(|selected| {
7988 selected.expected_remote.as_deref() == remote_hash
7989 && selected.selected_local.as_deref() == local_hash
7990 });
7991 if !explicitly_resolved {
7992 conflicts.push(path);
7993 continue;
7994 }
7995 }
7996 match local_file {
7997 Some((sha256, byte_count)) => {
7998 verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7999 operations.push(json!({
8000 "op": "put",
8001 "path": path,
8002 "expected": v2_expected(remote_file),
8003 "blob": sha256,
8004 "bytes": byte_count,
8005 }));
8006 upload_sources
8007 .entry(sha256.clone())
8008 .or_insert_with(|| V2UploadSource {
8009 path: path.clone(),
8010 bytes: *byte_count,
8011 });
8012 }
8013 None => {
8014 let Some(current) = remote_file else {
8015 continue;
8016 };
8017 operations.push(json!({
8018 "op": "delete",
8019 "path": path,
8020 "expected": { "kind": "blob", "hash": current.sha256 },
8021 }));
8022 }
8023 }
8024 }
8025 operations = infer_exact_source_promotions(operations);
8026 for path in &withdrawals {
8027 if local_assets.contains_key(path) {
8028 continue;
8029 }
8030 operations.push(v2_content_withdrawal_operation(
8031 store,
8032 &local_view,
8033 &remote,
8034 path,
8035 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8036 )?);
8037 consumed_withdrawals.insert(path.clone());
8038 }
8039 if !conflicts.is_empty() {
8040 conflicts.truncate(100);
8041 let (bundle, paths) = create_v2_conflict_bundle(
8042 cfg,
8043 store,
8044 &head,
8045 baseline.as_ref(),
8046 local,
8047 &remote,
8048 &conflicts,
8049 )?;
8050 return Err(LinkError::ConflictBundle { bundle, paths });
8051 }
8052 let base_assets = match baseline.as_ref() {
8053 Some(state) => &state.assets,
8054 None if remote_assets.is_empty() => &remote_assets,
8055 None => {
8056 let mismatched = remote_assets.iter().any(|(path, remote)| {
8057 local_assets.get(path) != Some(&v2_asset_record(remote, path))
8058 }) || local_assets.len() != remote_assets.len();
8059 if mismatched {
8060 return Err(LinkError::Conflict {
8061 paths: vec!["assets.jsonl".to_string()],
8062 });
8063 }
8064 &remote_assets
8065 }
8066 };
8067 let asset_paths = base_assets
8068 .keys()
8069 .chain(remote_assets.keys())
8070 .chain(local_assets.keys())
8071 .cloned()
8072 .collect::<std::collections::BTreeSet<_>>();
8073 let mut asset_policy_transitions = Vec::new();
8074 let mut asset_withdrawal_transitions = Vec::new();
8075 for path in asset_paths {
8076 let base_record = base_assets
8077 .get(&path)
8078 .map(|asset| v2_asset_record(asset, &path));
8079 let remote = remote_assets.get(&path);
8080 let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
8081 let local_record = local_assets.get(&path);
8082 if withdrawal_set.contains(&path) {
8083 let record = local_record.ok_or_else(|| LinkError::InvalidPack {
8084 message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
8085 })?;
8086 let current = remote.ok_or_else(|| LinkError::InvalidPack {
8087 message: format!(
8088 "asset withdrawal path `{path}` has no readable hosted coordinate"
8089 ),
8090 })?;
8091 operations.push(v2_asset_withdrawal_operation(
8092 store,
8093 &local_view,
8094 &path,
8095 record,
8096 current,
8097 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8098 )?);
8099 consumed_withdrawals.insert(path.clone());
8100 continue;
8101 }
8102 let mut raw_present = false;
8103 let mut disposition = "withheld";
8104 let mut resumes_hosting = false;
8105 if let Some(record) = local_record {
8106 crate::linkmd_v2::normalize_path(&record.path)
8107 .map_err(|error| invalid_feed(error.to_string()))?;
8108 let kept_home = local_view.policy.keeps_home(&path);
8109 raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
8110 disposition = if kept_home || !raw_present {
8111 "withheld"
8112 } else {
8113 "hosted"
8114 };
8115 let inherits_withheld_absence = v2_asset_inherits_withheld_absence(
8116 base_assets.get(&path),
8117 base_record.as_ref(),
8118 local_record,
8119 raw_present,
8120 );
8121 if !raw_present && record.required && !kept_home && !inherits_withheld_absence {
8122 return Err(LinkError::InvalidPack {
8123 message: format!("required asset {path} is missing"),
8124 });
8125 }
8126 resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
8127 if remote.is_some_and(|asset| asset.disposition == "hosted")
8128 && disposition == "withheld"
8129 {
8130 asset_withdrawal_transitions.push(path.clone());
8131 continue;
8132 }
8133 }
8134 if local_record == base_record.as_ref() && !resumes_hosting {
8135 continue;
8136 }
8137 if remote_record != base_record && local_record != remote_record.as_ref() {
8138 conflicts.push(path);
8139 continue;
8140 }
8141 let Some(record) = local_record else {
8142 if let Some(remote) = remote {
8143 operations.push(json!({
8144 "op": "asset_delete",
8145 "path": path,
8146 "expected": v2_asset_expected(Some(remote)),
8147 }));
8148 }
8149 continue;
8150 };
8151 let raw = if raw_present {
8152 verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
8153 Some(())
8154 } else {
8155 None
8156 };
8157 let op = if resumes_hosting {
8158 if !resume_local_policy {
8159 asset_policy_transitions.push(path);
8160 continue;
8161 }
8162 "asset_resume"
8163 } else {
8164 "asset_put"
8165 };
8166 operations.push(json!({
8167 "op": op,
8168 "path": path,
8169 "expected": v2_asset_expected(remote),
8170 "asset": v2_asset_value(record, disposition),
8171 }));
8172 if disposition == "hosted" {
8173 raw.expect("hosted asset was checked present");
8174 upload_sources
8175 .entry(record.sha256.clone())
8176 .or_insert_with(|| V2UploadSource {
8177 path: path.clone(),
8178 bytes: record.bytes,
8179 });
8180 }
8181 }
8182 if consumed_withdrawals != withdrawal_set {
8183 let missing = withdrawal_set
8184 .difference(&consumed_withdrawals)
8185 .next()
8186 .expect("different withdrawal sets have one member");
8187 return Err(LinkError::InvalidPack {
8188 message: format!(
8189 "withdrawal path `{missing}` is not a readable content or asset coordinate"
8190 ),
8191 });
8192 }
8193 if !conflicts.is_empty() {
8194 conflicts.truncate(100);
8195 return Err(LinkError::Conflict { paths: conflicts });
8196 }
8197 if !asset_policy_transitions.is_empty() {
8198 asset_policy_transitions.truncate(100);
8199 return Err(LinkError::LocalPolicyTransition {
8200 paths: asset_policy_transitions,
8201 });
8202 }
8203 if !asset_withdrawal_transitions.is_empty() {
8204 asset_withdrawal_transitions.truncate(100);
8205 return Err(LinkError::AssetWithdrawalRequired {
8206 paths: asset_withdrawal_transitions,
8207 });
8208 }
8209 let touched_sources = operations
8210 .iter()
8211 .filter_map(
8212 |operation| match operation.get("op").and_then(Value::as_str) {
8213 Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
8214 Some("rename") => operation.get("to").and_then(Value::as_str),
8215 _ => None,
8216 },
8217 )
8218 .collect::<std::collections::BTreeSet<_>>();
8219 let withheld_links = local_view
8220 .withheld_links
8221 .iter()
8222 .filter(|link| touched_sources.contains(link.source.as_str()))
8223 .collect::<Vec<_>>();
8224 let checkout_pseudonym = v2_checkout_id(
8225 baseline
8226 .as_ref()
8227 .and_then(|current| current.checkout_id.as_deref()),
8228 )?;
8229 let checkout_id = if withheld_links.is_empty() {
8230 None
8231 } else {
8232 Some(checkout_pseudonym.clone())
8233 };
8234 if operations.is_empty() {
8235 let final_head = v2_verified_head(cfg, requested_brain)?
8236 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
8237 if !same_v2_head(&head, &final_head) {
8238 return Err(LinkError::RemoteAdvancedDuringSync);
8239 }
8240 let mut final_local = v2_local_files(store)?;
8241 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8242 let final_assets = v2_local_asset_records(store)?;
8243 let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
8244 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
8245 final_local.policy.keeps_home(path)
8246 }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
8247 let next = v2_baseline_from_head(
8248 cfg,
8249 &head,
8250 remote,
8251 remote_assets,
8252 Some(&final_local),
8253 Some(&checkout_pseudonym),
8254 )?;
8255 let split_count = next.remote_copy_remains.len();
8256 accept_v2_head(cfg, &final_head)?;
8257 if !local_changed && !remote_ahead {
8258 refresh_scoped_view_marker(store, &head, next.files.len())?;
8259 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8260 }
8261 return Ok(json!({
8262 "v": 2,
8263 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
8264 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
8265 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8266 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8267 "local_policy": {
8268 "remote_copy_remains": split_count,
8269 },
8270 }));
8271 }
8272 let includes_contract = operations
8273 .iter()
8274 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
8275 let rebase = if head.pointer.is_none() || includes_contract {
8276 "strict"
8277 } else {
8278 "disjoint"
8279 };
8280 let base_value = head.pointer.as_ref().map(|pointer| {
8281 json!({
8282 "seq": pointer.seq,
8283 "commit_hash": pointer.commit_hash,
8284 "content_root": pointer.content_root,
8285 "asset_root": pointer.asset_root,
8286 })
8287 });
8288 let entropy = format!(
8292 "{}\0{}\0{}\0{}\0{}\0{}",
8293 normalized_origin(&cfg.hub)?,
8294 head.brain_id,
8295 serde_json::to_string(&base_value).unwrap_or_default(),
8296 serde_json::to_string(&operations).unwrap_or_default(),
8297 serde_json::to_string(&withheld_links).unwrap_or_default(),
8298 checkout_id.as_deref().unwrap_or("")
8299 );
8300 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
8301 let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
8302 total
8303 .checked_add(source.bytes)
8304 .ok_or_else(|| LinkError::PushTooLarge {
8305 detail: "v2 changed-byte total overflow".to_string(),
8306 })
8307 })?;
8308 let inline = changed_bytes <= 3 * 1024 * 1024;
8309 let inline_blobs = if inline {
8310 upload_sources
8311 .iter()
8312 .map(|(sha256, source)| {
8313 let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
8314 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
8315 return Err(LinkError::InvalidPack {
8316 message: format!("local path `{}` changed before upload", source.path),
8317 });
8318 }
8319 Ok(json!({
8320 "sha256": sha256,
8321 "bytes": source.bytes,
8322 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
8323 }))
8324 })
8325 .collect::<LinkResult<Vec<_>>>()?
8326 } else {
8327 Vec::new()
8328 };
8329 let mut body = json!({
8330 "mutation_id": mutation_id,
8331 "base": base_value,
8332 "rebase": rebase,
8333 "reason": "dbmd sync",
8334 "operations": operations,
8335 "blobs": inline_blobs,
8336 });
8337 if !withheld_links.is_empty() {
8338 body["withheld_links"] = serde_json::to_value(&withheld_links)
8339 .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
8340 body["checkout_id"] =
8341 Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
8342 }
8343 if let Some(confirmation) = bulk_confirmation {
8344 if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
8345 return Err(LinkError::InvalidPack {
8346 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
8347 .to_string(),
8348 });
8349 }
8350 body["rebase"] = Value::String("strict".to_string());
8354 body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8355 body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8356 }
8357 if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8358 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8359 for operation in &operations {
8360 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8361 return Err(invalid_feed("v2 upload operation has no kind"));
8362 };
8363 let hash = match kind {
8364 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
8365 "asset_put" | "asset_resume" => operation
8366 .get("asset")
8367 .and_then(|asset| asset.get("blob_sha256"))
8368 .and_then(Value::as_str),
8369 _ => None,
8370 };
8371 let Some(hash) = hash else { continue };
8372 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8373 if kind == "rename" {
8374 for field in ["from", "to"] {
8375 coordinates.insert(
8376 operation
8377 .get(field)
8378 .and_then(Value::as_str)
8379 .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8380 .to_string(),
8381 );
8382 }
8383 } else {
8384 let path = operation
8385 .get("path")
8386 .and_then(Value::as_str)
8387 .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8388 coordinates.insert(if kind.starts_with("asset_") {
8389 format!("assets/{path}")
8390 } else {
8391 path.to_string()
8392 });
8393 }
8394 }
8395 let declarations = upload_sources
8396 .iter()
8397 .map(|(sha256, source)| {
8398 json!({
8399 "sha256": sha256,
8400 "bytes": source.bytes,
8401 "coordinates": coordinates_by_hash
8402 .get(sha256)
8403 .into_iter()
8404 .flatten()
8405 .collect::<Vec<_>>(),
8406 })
8407 })
8408 .collect::<Vec<_>>();
8409 let mut references = Vec::with_capacity(upload_sources.len());
8410 let mut seen = std::collections::BTreeSet::new();
8411 let mut reserved_count = 0usize;
8412 let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8413 for batch in batch_upload_declarations(declarations) {
8417 let batch_len = batch.len();
8418 let reserved = reserve_upload_window(
8419 cfg,
8420 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8421 &json!({ "blobs": batch }),
8422 "prepare v2 changed-byte uploads",
8423 )?;
8424 let items = reserved
8425 .get("uploads")
8426 .and_then(Value::as_array)
8427 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8428 if items.len() != batch_len {
8429 return Err(invalid_feed(
8430 "v2 upload reservation response changed the requested set",
8431 ));
8432 }
8433 reserved_count += items.len();
8434 for item in items {
8435 let sha256 = item
8436 .get("sha256")
8437 .and_then(Value::as_str)
8438 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8439 let source = upload_sources
8440 .get(sha256)
8441 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8442 let declared_bytes = item
8443 .get("bytes")
8444 .and_then(Value::as_u64)
8445 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8446 let reservation_id = item
8447 .get("reservation_id")
8448 .and_then(Value::as_str)
8449 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8450 let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8451 invalid_feed("v2 upload reservation has no coordinate binding")
8452 })?;
8453 let returned_coordinates = item
8454 .get("coordinates")
8455 .and_then(Value::as_array)
8456 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8457 if declared_bytes != source.bytes
8458 || !crate::ulid::is_ulid(reservation_id)
8459 || !seen.insert(sha256.to_string())
8460 || returned_coordinates.len() != expected_coordinates.len()
8461 || returned_coordinates
8462 .iter()
8463 .zip(expected_coordinates)
8464 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8465 {
8466 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8467 }
8468 match item.get("status").and_then(Value::as_str) {
8469 Some("upload") => {
8470 let url = item
8471 .get("url")
8472 .and_then(Value::as_str)
8473 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8474 pending_uploads.push(V2PendingUpload {
8475 url: url.to_string(),
8476 headers: item.get("headers").cloned().unwrap_or(Value::Null),
8477 sha256: sha256.to_string(),
8478 source,
8479 });
8480 }
8481 Some("already_present") => {}
8482 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
8483 }
8484 references.push(json!({
8485 "sha256": sha256,
8486 "bytes": source.bytes,
8487 "reservation_id": reservation_id,
8488 }));
8489 }
8490 upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
8496 pending_uploads.clear();
8497 }
8498 if reserved_count != upload_sources.len() {
8499 return Err(invalid_feed(
8500 "v2 upload reservation response changed the requested set",
8501 ));
8502 }
8503 body["blobs"] = Value::Array(references);
8504 }
8505 stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
8506 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
8507 let mut candidate_hub_signer: Option<String> = None;
8508 let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8509 let bulk_preview_required = !(200..300).contains(&response.status)
8510 && response.body.as_ref().is_some_and(|value| {
8511 value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
8512 || value
8513 .get("details")
8514 .and_then(|details| details.get("code"))
8515 .and_then(Value::as_str)
8516 == Some("bulk_preview_required")
8517 });
8518 if bulk_preview_required && bulk_confirmation.is_none() {
8519 body["rebase"] = Value::String("strict".to_string());
8520 body["preview_only"] = Value::Bool(true);
8521 let preview = ensure_ok(
8522 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
8523 "v2 bulk preview",
8524 )?;
8525 let preview_code = preview.get("code").and_then(Value::as_str);
8526 let required = preview.get("required").and_then(Value::as_bool);
8527 if preview.get("v").and_then(Value::as_u64) != Some(2)
8528 || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
8529 || !matches!(
8530 preview_code,
8531 Some("bulk_preview_created" | "bulk_preview_not_required")
8532 )
8533 || required.is_none()
8534 {
8535 return Err(invalid_feed(
8536 "bulk preview response is not bound to the requested mutation",
8537 ));
8538 }
8539 if required == Some(true) {
8540 let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
8541 let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
8542 if preview_code != Some("bulk_preview_created")
8543 || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
8544 || preview_digest.is_none_or(|value| !is_sha256(value))
8545 || preview.get("expires_at").and_then(Value::as_str).is_none()
8546 || !preview.get("impact").is_some_and(Value::is_object)
8547 {
8548 return Err(invalid_feed("bulk preview receipt is malformed"));
8549 }
8550 return Err(LinkError::BulkPreviewRequired { preview });
8551 }
8552 if preview_code != Some("bulk_preview_not_required") {
8553 return Err(invalid_feed("bulk preview requirement is inconsistent"));
8554 }
8555 body.as_object_mut()
8558 .expect("v2 commit request is an object")
8559 .remove("preview_only");
8560 response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8561 }
8562 let mut result = ensure_ok(response, "v2 sync push")?;
8563 if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
8564 if let Some(object) = result.as_object_mut() {
8565 object.insert(
8566 "sync_status".to_string(),
8567 Value::String("proposal_pending".to_string()),
8568 );
8569 }
8570 return Ok(result);
8571 }
8572 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
8573 let request_id = result
8574 .get("request_id")
8575 .and_then(Value::as_str)
8576 .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
8577 .to_string();
8578 let challenge = result
8579 .get("signing_challenge")
8580 .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
8581 let mut expected_candidate = remote.clone();
8582 let mut expected_candidate_assets = remote_assets.clone();
8583 apply_generated_v2_operations(
8584 &operations,
8585 &local_assets,
8586 &mut expected_candidate,
8587 &mut expected_candidate_assets,
8588 )?;
8589 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
8590 cfg,
8591 &head,
8592 &expected_candidate,
8593 &expected_candidate_assets,
8594 &mutation_id,
8595 &v2_signed_request_view(&body, &operations),
8596 challenge,
8597 )?;
8598 body["signing_challenge_id"] = Value::String(challenge_id);
8599 body["signature_base64url"] = Value::String(signature);
8600 candidate_hub_signer = Some(actor_signer);
8601 result = ensure_ok(
8602 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
8603 "v2 self-custody commit",
8604 )?;
8605 }
8606 let refreshed = v2_verified_head(cfg, requested_brain)?
8607 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
8608 if candidate_hub_signer
8609 .as_ref()
8610 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
8611 {
8612 return Err(invalid_feed(
8613 "self-custody actor signer differs from the committed hub pointer signer",
8614 ));
8615 }
8616 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
8617 if refreshed
8618 .pointer
8619 .as_ref()
8620 .map(|pointer| pointer.commit_hash.as_str())
8621 != accepted_hash
8622 {
8623 return Err(LinkError::RemoteAdvancedDuringSync);
8624 }
8625 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
8626 let rebased = result
8627 .get("rebased")
8628 .and_then(Value::as_bool)
8629 .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
8630 let (refreshed_files, refreshed_assets) = if rebased {
8631 (
8632 files_for_v2_view(
8633 &refreshed,
8634 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8635 ),
8636 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8637 )
8638 } else {
8639 let asset_changed = apply_generated_v2_operations(
8640 &operations,
8641 &local_assets,
8642 &mut remote,
8643 &mut remote_assets,
8644 )?;
8645 let assets = if asset_changed {
8646 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
8649 } else {
8650 remote_assets
8651 };
8652 (remote, assets)
8653 };
8654 let mut final_local = v2_local_files(store)?;
8655 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
8656 let final_assets = v2_local_asset_records(store)?;
8657 let local_dirty = final_local.riding != local_view.riding
8658 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
8659 final_local.policy.keeps_home(path)
8660 })
8661 || final_assets != local_assets
8662 || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
8663 let next = v2_baseline_from_head(
8664 cfg,
8665 &refreshed,
8666 refreshed_files,
8667 refreshed_assets,
8668 Some(&final_local),
8669 Some(&checkout_pseudonym),
8670 )?;
8671 let split_count = next.remote_copy_remains.len();
8672 accept_v2_head(cfg, &refreshed)?;
8673 if !local_dirty {
8674 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
8675 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
8676 }
8677 if let Some(object) = result.as_object_mut() {
8678 object.insert(
8679 "local_policy".to_string(),
8680 json!({ "remote_copy_remains": split_count }),
8681 );
8682 object.insert(
8683 "sync_status".to_string(),
8684 Value::String(if local_dirty {
8685 "remote_committed_local_dirty".to_string()
8686 } else {
8687 "synced".to_string()
8688 }),
8689 );
8690 }
8691 Ok(result)
8692}
8693
8694pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
8697 sync_push_incremental_with_policy(cfg, brain, store, false)
8698}
8699
8700pub fn sync_push_incremental_with_policy(
8703 cfg: &HubConfig,
8704 brain: &str,
8705 store: &Store,
8706 resume_local_policy: bool,
8707) -> LinkResult<Value> {
8708 sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
8709}
8710
8711pub fn sync_push_incremental_with_options(
8714 cfg: &HubConfig,
8715 brain: &str,
8716 store: &Store,
8717 resume_local_policy: bool,
8718 bulk_confirmation: Option<&V2BulkConfirmation>,
8719) -> LinkResult<Value> {
8720 sync_push_incremental_with_controls(
8721 cfg,
8722 brain,
8723 store,
8724 resume_local_policy,
8725 bulk_confirmation,
8726 &[],
8727 None,
8728 )
8729}
8730
8731pub fn sync_push_incremental_with_controls(
8733 cfg: &HubConfig,
8734 brain: &str,
8735 store: &Store,
8736 resume_local_policy: bool,
8737 bulk_confirmation: Option<&V2BulkConfirmation>,
8738 withdrawal_paths: &[String],
8739 withdrawal_reason: Option<&str>,
8740) -> LinkResult<Value> {
8741 require_safe_ref(brain)?;
8742 if let Some(head) = v2_verified_head(cfg, brain)? {
8743 return v2_sync_push(
8744 cfg,
8745 brain,
8746 store,
8747 head,
8748 V2SyncPushOptions {
8749 resume_local_policy,
8750 bulk_confirmation,
8751 resolution: None,
8752 pulled: None,
8753 withdrawal_paths,
8754 withdrawal_reason,
8755 },
8756 );
8757 }
8758 if !withdrawal_paths.is_empty() {
8759 return Err(LinkError::InvalidPack {
8760 message: "explicit withdrawal requires a link.md v2 brain".to_string(),
8761 });
8762 }
8763 legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
8764}
8765
8766pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
8770 require_safe_ref(brain)?;
8771 Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
8772}
8773
8774#[cfg(windows)]
8775fn legacy_sync_push_incremental(
8776 _cfg: &HubConfig,
8777 _brain: &str,
8778 _store: &Store,
8779 _resume_local_policy: bool,
8780 _bulk_confirmation: Option<&V2BulkConfirmation>,
8781) -> LinkResult<Value> {
8782 Err(LinkError::UnsupportedPlatform {
8783 operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
8784 })
8785}
8786
8787#[cfg(not(windows))]
8788fn legacy_sync_push_incremental(
8789 cfg: &HubConfig,
8790 brain: &str,
8791 store: &Store,
8792 resume_local_policy: bool,
8793 bulk_confirmation: Option<&V2BulkConfirmation>,
8794) -> LinkResult<Value> {
8795 if resume_local_policy || bulk_confirmation.is_some() {
8796 return Err(LinkError::InvalidPack {
8797 message: "v2 sync options require a link.md v2 brain".to_string(),
8798 });
8799 }
8800 let files = collect_push_files(store)?;
8801 sync_push(cfg, brain, &files)
8802}
8803
8804#[derive(Debug, Clone)]
8806pub enum V2ConflictChoice {
8807 KeepLocal,
8808 TakeRemote,
8809 From(PathBuf),
8810}
8811
8812fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
8813 if !crate::ulid::is_ulid(bundle) {
8814 return Err(LinkError::InvalidPack {
8815 message: "conflict bundle must be a lowercase ULID".to_string(),
8816 });
8817 }
8818 let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
8819 let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
8820 .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
8821 if plan.v != 2
8822 || plan.class != "content_resolution_required"
8823 || plan.bundle != bundle
8824 || !crate::ulid::is_ulid(&plan.brain)
8825 || plan.files.is_empty()
8826 || plan.files.len() > 100
8827 || plan.files.iter().any(|file| {
8828 crate::linkmd_v2::normalize_path(&file.path).is_err()
8829 || [&file.base, &file.local, &file.remote]
8830 .into_iter()
8831 .any(|coordinate| {
8832 coordinate
8833 .sha256
8834 .as_deref()
8835 .is_some_and(|hash| !is_sha256(hash))
8836 || coordinate.file.as_deref().is_some_and(|name| {
8837 name.starts_with('/')
8838 || name
8839 .split('/')
8840 .any(|part| part.is_empty() || part == "." || part == "..")
8841 })
8842 })
8843 })
8844 {
8845 return Err(invalid_feed("private conflict plan failed validation"));
8846 }
8847 Ok(plan)
8848}
8849
8850pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
8855 require_hardened_filesystem("private conflict maintenance")?;
8856 if all && !prune {
8857 return Err(LinkError::InvalidPack {
8858 message: "discarding all conflict bundles requires prune=true".to_string(),
8859 });
8860 }
8861 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8862 message: format!("conflict checkout is not a valid db.md store: {error}"),
8863 })?;
8864 let _transaction = store.transaction()?;
8865 let root = Path::new(".dbmd/conflicts");
8866 let names = match store.directory_names(root) {
8867 Ok(names) => names,
8868 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8869 Err(error) => return Err(error.into()),
8870 };
8871 let now = SystemTime::now()
8872 .duration_since(UNIX_EPOCH)
8873 .unwrap_or_default()
8874 .as_secs();
8875 let mut bundles = Vec::new();
8876 let mut pruned = 0_u64;
8877 for name in names {
8878 let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
8879 continue;
8880 };
8881 let plan_path = v2_conflict_relative(bundle, "plan.json");
8882 let plan_exists = store.regular_file_exists(&plan_path)?;
8883 let expired = if plan_exists {
8884 match load_v2_conflict_plan(&store, bundle) {
8885 Ok(plan) => plan.expires_unix < now,
8886 Err(error) if all => {
8887 let _ = error;
8888 true
8889 }
8890 Err(error) => return Err(error),
8891 }
8892 } else {
8893 true
8894 };
8895 if prune && (all || expired) {
8896 store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8897 pruned += 1;
8898 continue;
8899 }
8900 bundles.push(json!({
8901 "bundle": bundle,
8902 "complete": plan_exists,
8903 "expired": expired,
8904 }));
8905 }
8906 Ok(json!({
8907 "v": 2,
8908 "class": "private_conflict_state",
8909 "bundles": bundles.len(),
8910 "pruned": pruned,
8911 "items": bundles,
8912 }))
8913}
8914
8915pub fn sync_resolve_conflict(
8919 cfg: &HubConfig,
8920 checkout: &Path,
8921 bundle: &str,
8922 choice: V2ConflictChoice,
8923 bulk_confirmation: Option<&V2BulkConfirmation>,
8924) -> LinkResult<Value> {
8925 require_hardened_filesystem("conflict resolution")?;
8926 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8927 message: format!("conflict checkout is not a valid db.md store: {error}"),
8928 })?;
8929 let plan = load_v2_conflict_plan(&store, bundle)?;
8930 if plan.origin != normalized_origin(&cfg.hub)? {
8931 return Err(invalid_feed(
8932 "conflict bundle belongs to another hub origin",
8933 ));
8934 }
8935 let now = SystemTime::now()
8936 .duration_since(UNIX_EPOCH)
8937 .unwrap_or_default()
8938 .as_secs();
8939 if now > plan.expires_unix {
8940 return Err(LinkError::InvalidPack {
8941 message: "conflict bundle expired; rerun sync to obtain current coordinates"
8942 .to_string(),
8943 });
8944 }
8945 let head = v2_verified_head(cfg, &plan.brain)?
8946 .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8947 let pointer = head.pointer.as_ref();
8948 if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8949 || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8950 || pointer.and_then(|value| value.content_root.as_deref())
8951 != plan.remote_content_root.as_deref()
8952 || head.view_kind != plan.view_kind
8953 || head.view_revision != plan.view_revision
8954 {
8955 return Err(LinkError::RemoteAdvancedDuringSync);
8956 }
8957
8958 for file in &plan.files {
8960 let actual = match store.regular_file_exists(Path::new(&file.path))? {
8961 true => Some(content_sha256(&store.read_bounded(
8962 Path::new(&file.path),
8963 file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8964 )?)),
8965 false => None,
8966 };
8967 if actual.as_deref() != file.local.sha256.as_deref() {
8968 return Err(LinkError::InvalidPack {
8969 message: format!(
8970 "local conflict path `{}` changed after the bundle was created",
8971 file.path
8972 ),
8973 });
8974 }
8975 }
8976
8977 let from_source = match &choice {
8978 V2ConflictChoice::From(source) => Some(source.clone()),
8979 _ => None,
8980 };
8981 let result = match choice {
8982 V2ConflictChoice::TakeRemote => {
8983 if bulk_confirmation.is_some() {
8984 return Err(LinkError::InvalidPack {
8985 message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8986 });
8987 }
8988 let current_remote =
8992 files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8993 let _ = v2_take_remote_selection(&plan.files, ¤t_remote)?;
8994 let selected = plan
8995 .files
8996 .iter()
8997 .map(|file| file.path.clone())
8998 .collect::<std::collections::BTreeSet<_>>();
8999 serde_json::to_value(
9000 v2_sync_pull_with_resolution(
9001 cfg,
9002 &plan.brain,
9003 head,
9004 Some(checkout),
9005 Some(&selected),
9006 )?
9007 .report,
9008 )
9009 .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
9010 }
9011 V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
9012 if let Some(source) = from_source.as_ref() {
9013 if plan.files.len() != 1 {
9014 return Err(LinkError::InvalidPack {
9015 message: "--from requires a bundle with exactly one conflict".to_string(),
9016 });
9017 }
9018 let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
9019 if std::str::from_utf8(&candidate).is_err() {
9020 return Err(LinkError::NotUtf8 {
9021 path: source.display().to_string(),
9022 });
9023 }
9024 store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
9025 }
9026 let refreshed_store =
9027 Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9028 message: format!("resolved checkout is not a valid db.md store: {error}"),
9029 })?;
9030 let mut overrides = std::collections::BTreeMap::new();
9031 for file in &plan.files {
9032 let selected_local = match refreshed_store
9033 .regular_file_exists(Path::new(&file.path))?
9034 {
9035 true => Some(content_sha256(
9036 &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
9037 )),
9038 false => None,
9039 };
9040 overrides.insert(
9041 file.path.clone(),
9042 V2ResolutionOverride {
9043 expected_remote: file.remote.sha256.clone(),
9044 selected_local,
9045 },
9046 );
9047 }
9048 v2_sync_push(
9049 cfg,
9050 &plan.brain,
9051 &refreshed_store,
9052 head,
9053 V2SyncPushOptions {
9054 resume_local_policy: true,
9055 bulk_confirmation,
9056 resolution: Some(&overrides),
9057 pulled: None,
9058 withdrawal_paths: &[],
9059 withdrawal_reason: None,
9060 },
9061 )?
9062 }
9063 };
9064
9065 if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9066 let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9067 message: format!("resolved checkout is not a valid db.md store: {error}"),
9068 })?;
9069 installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9070 }
9071 Ok(json!({
9072 "v": 2,
9073 "class": "auto_converged",
9074 "bundle": bundle,
9075 "receipt": result,
9076 }))
9077}
9078
9079pub fn sync_converge(
9090 cfg: &HubConfig,
9091 brain: &str,
9092 checkout: &Path,
9093 resume_local_policy: bool,
9094) -> LinkResult<Value> {
9095 sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
9096}
9097
9098pub fn sync_converge_with_options(
9100 cfg: &HubConfig,
9101 brain: &str,
9102 checkout: &Path,
9103 resume_local_policy: bool,
9104 bulk_confirmation: Option<&V2BulkConfirmation>,
9105) -> LinkResult<Value> {
9106 sync_converge_with_controls(
9107 cfg,
9108 brain,
9109 checkout,
9110 resume_local_policy,
9111 bulk_confirmation,
9112 &[],
9113 None,
9114 )
9115}
9116
9117pub fn sync_converge_with_controls(
9119 cfg: &HubConfig,
9120 brain: &str,
9121 checkout: &Path,
9122 resume_local_policy: bool,
9123 bulk_confirmation: Option<&V2BulkConfirmation>,
9124 withdrawal_paths: &[String],
9125 withdrawal_reason: Option<&str>,
9126) -> LinkResult<Value> {
9127 require_hardened_filesystem("bidirectional sync")?;
9128 require_safe_ref(brain)?;
9129 let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
9130 message:
9131 "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
9132 .to_string(),
9133 })?;
9134 let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
9135 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9136 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9137 })?;
9138 let _transaction = store.transaction()?;
9139 let pulled_report = pulled.report.clone();
9140 let pulled_head = pulled.head.clone();
9141 let mut result = v2_sync_push(
9142 cfg,
9143 brain,
9144 &store,
9145 pulled_head,
9146 V2SyncPushOptions {
9147 resume_local_policy,
9148 bulk_confirmation,
9149 resolution: None,
9150 pulled: Some(pulled),
9151 withdrawal_paths,
9152 withdrawal_reason,
9153 },
9154 )?;
9155 if let Some(object) = result.as_object_mut() {
9156 object.insert("pulled_files".to_string(), json!(pulled_report.files));
9157 object.insert("checkout".to_string(), Value::String(pulled_report.dest));
9158 object.insert(
9159 "mode".to_string(),
9160 Value::String("bidirectional".to_string()),
9161 );
9162 }
9163 Ok(result)
9164}
9165
9166pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9172 require_hardened_filesystem("sync pull")?;
9173 require_safe_ref(brain)?;
9174 if let Some(head) = v2_verified_head(cfg, brain)? {
9175 return v2_sync_pull(cfg, brain, head, out);
9176 }
9177 legacy_sync_pull(cfg, brain, out)
9178}
9179
9180#[cfg(windows)]
9181fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
9182 Err(LinkError::UnsupportedPlatform {
9183 operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
9184 })
9185}
9186
9187#[cfg(not(windows))]
9188fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9189 let remote = verified_remote_head(cfg, brain, false)?;
9190 if !remote.head.verified {
9191 return Err(invalid_feed(
9192 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
9193 ));
9194 }
9195 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
9196 let path = format!(
9197 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
9198 remote.head.seq
9199 );
9200 let body = ensure_ok(
9201 request(cfg, "GET", &path, None, Auth::Required)?,
9202 "sync pull",
9203 )?;
9204 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
9205 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
9206 {
9207 return Err(invalid_feed(
9208 "export response is not bound to the verified snapshot token",
9209 ));
9210 }
9211
9212 let remote_slug = body
9213 .get("slug")
9214 .and_then(Value::as_str)
9215 .filter(|slug| is_safe_slug(slug));
9216 let slug = remote_slug
9217 .or_else(|| is_safe_slug(brain).then_some(brain))
9218 .unwrap_or("brain")
9219 .to_string();
9220 let brain_id = body
9221 .get("brain")
9222 .and_then(Value::as_str)
9223 .unwrap_or(&remote.head.brain)
9224 .to_string();
9225 if brain_id != remote.head.brain {
9226 return Err(invalid_feed(
9227 "export response names a different brain than the verified head",
9228 ));
9229 }
9230 let head_seq = remote.head.seq;
9231 let dest: PathBuf = match out {
9232 Some(p) => p.to_path_buf(),
9233 None => PathBuf::from(&slug),
9234 };
9235 let entries = if head_seq == 0 {
9236 let files = body
9237 .get("files")
9238 .and_then(Value::as_array)
9239 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
9240 if !files.is_empty() || body.get("url").is_some() {
9241 return Err(invalid_feed(
9242 "empty signed feed cannot authorize non-empty exported content",
9243 ));
9244 }
9245 Vec::new()
9246 } else {
9247 let signed_head = remote
9248 .head_entry
9249 .as_ref()
9250 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
9251 let expected = &signed_head.entry.pack_sha256;
9252 if !is_sha256(expected) {
9253 return Err(invalid_feed(
9254 "signed head carries an invalid snapshot pack digest",
9255 ));
9256 }
9257 if let Some(url) = body.get("url").and_then(Value::as_str) {
9258 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
9259 return Err(invalid_feed(
9260 "export pack digest does not match the signed head entry",
9261 ));
9262 }
9263 let bytes = get_presigned(cfg, url)?;
9264 let actual = format!("{:x}", Sha256::digest(&bytes));
9265 if actual != *expected {
9266 return Err(LinkError::InvalidPack {
9267 message: "downloaded pack does not match the signed snapshot digest"
9268 .to_string(),
9269 });
9270 }
9271 let entries = parse_store_pack(bytes)?;
9272 if signed_head.entry.kind == "push" {
9273 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9274 }
9275 entries
9276 } else {
9277 if signed_head.entry.kind != "push" {
9278 return Err(invalid_feed(
9279 "delta snapshots must export the exact signed pack",
9280 ));
9281 }
9282 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
9283 invalid_feed("verified snapshot export carried neither a pack nor files")
9284 })?;
9285 let mut entries = Vec::with_capacity(files.len());
9286 for file in files {
9287 let path = file
9288 .get("path")
9289 .and_then(Value::as_str)
9290 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
9291 let content = file
9292 .get("content")
9293 .and_then(Value::as_str)
9294 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
9295 entries.push((path.to_string(), content.as_bytes().to_vec()));
9296 }
9297 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9298 entries
9299 }
9300 };
9301
9302 let mut seen = std::collections::HashSet::new();
9304 for (path, _) in &entries {
9305 if !safe_store_rel_path(path) {
9306 return Err(LinkError::UnsafePath { path: path.clone() });
9307 }
9308 if !seen.insert(path) {
9309 return Err(LinkError::InvalidPack {
9310 message: format!("duplicate path `{path}`"),
9311 });
9312 }
9313 }
9314 let pulled: std::collections::BTreeSet<&str> =
9317 entries.iter().map(|(p, _)| p.as_str()).collect();
9318 let mut extra_local = Vec::new();
9319 if let Ok(store) = Store::open(&dest) {
9320 if let Ok(walked) = store.walk() {
9321 for rel in walked {
9322 let rel_str = rel.to_string_lossy().replace('\\', "/");
9323 if !pulled.contains(rel_str.as_str()) {
9324 extra_local.push(rel_str);
9325 }
9326 }
9327 }
9328 }
9329 #[cfg(unix)]
9330 install_pulled_snapshot(&dest, &entries)?;
9331
9332 Ok(PullReport {
9333 brain: brain_id,
9334 slug,
9335 head_seq,
9336 files: entries.len(),
9337 dest: dest.to_string_lossy().into_owned(),
9338 extra_local,
9339 sync_status: "synced".to_string(),
9340 })
9341}
9342
9343#[cfg(unix)]
9344fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
9345 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
9346 path: display.to_string(),
9347 })
9348}
9349
9350#[cfg(unix)]
9351fn open_dir_at(
9352 parent: std::os::fd::RawFd,
9353 name: &std::ffi::CStr,
9354 display: &str,
9355) -> LinkResult<std::fs::File> {
9356 use std::os::fd::FromRawFd as _;
9357 let fd = unsafe {
9358 libc::openat(
9359 parent,
9360 name.as_ptr(),
9361 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9362 )
9363 };
9364 if fd < 0 {
9365 return Err(LinkError::UnsafePath {
9366 path: display.to_string(),
9367 });
9368 }
9369 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
9370}
9371
9372#[cfg(unix)]
9376fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
9377 use std::os::fd::AsRawFd as _;
9378
9379 #[cfg(target_os = "macos")]
9383 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
9384 .into_iter()
9385 .find_map(|(alias, real)| {
9386 path.strip_prefix(alias)
9387 .ok()
9388 .map(|rest| Path::new(real).join(rest))
9389 })
9390 .unwrap_or_else(|| path.to_path_buf());
9391 #[cfg(not(target_os = "macos"))]
9392 let normalized = path.to_path_buf();
9393
9394 let start = if normalized.is_absolute() {
9395 std::fs::File::open("/")?
9396 } else {
9397 std::fs::File::open(".")?
9398 };
9399 let mut directory = start;
9400 for component in normalized.components() {
9401 use std::path::Component;
9402 let name = match component {
9403 Component::RootDir | Component::CurDir => continue,
9404 Component::Normal(name) => name,
9405 Component::ParentDir | Component::Prefix(_) => {
9406 return Err(LinkError::UnsafePath {
9407 path: path.display().to_string(),
9408 });
9409 }
9410 };
9411 use std::os::unix::ffi::OsStrExt as _;
9412 let name = c_name(name.as_bytes(), &path.display().to_string())?;
9413 if create {
9414 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9415 if made != 0 {
9416 let error = std::io::Error::last_os_error();
9417 if error.raw_os_error() != Some(libc::EEXIST) {
9418 return Err(error.into());
9419 }
9420 }
9421 }
9422 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
9423 }
9424 Ok(directory)
9425}
9426
9427#[cfg(unix)]
9428fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9429 open_dir_path_nofollow(path, true)
9430}
9431
9432#[cfg(unix)]
9433fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9434 open_dir_path_nofollow(path, false)
9435}
9436
9437#[cfg(unix)]
9438fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
9439 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9440 let result =
9441 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
9442 if result == 0 {
9443 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
9444 }
9445 let error = std::io::Error::last_os_error();
9446 if error.kind() == std::io::ErrorKind::NotFound {
9447 Ok(None)
9448 } else {
9449 Err(error.into())
9450 }
9451}
9452
9453#[cfg(unix)]
9454fn create_dir_exclusive_at(
9455 parent: std::os::fd::RawFd,
9456 name: &std::ffi::CStr,
9457 display: &str,
9458) -> LinkResult<std::fs::File> {
9459 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
9460 if made != 0 {
9461 return Err(LinkError::UnsafePath {
9462 path: display.to_string(),
9463 });
9464 }
9465 open_dir_at(parent, name, display)
9466}
9467
9468#[cfg(unix)]
9469fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
9470 use std::os::fd::AsRawFd as _;
9471
9472 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
9473 if duplicate < 0 {
9474 return Err(std::io::Error::last_os_error().into());
9475 }
9476 let stream = unsafe { libc::fdopendir(duplicate) };
9477 if stream.is_null() {
9478 let error = std::io::Error::last_os_error();
9479 unsafe {
9480 libc::close(duplicate);
9481 }
9482 return Err(error.into());
9483 }
9484 let mut names = Vec::new();
9485 loop {
9486 let entry = unsafe { libc::readdir(stream) };
9487 if entry.is_null() {
9488 break;
9489 }
9490 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
9491 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
9492 names.push(raw.to_owned());
9493 }
9494 }
9495 if unsafe { libc::closedir(stream) } != 0 {
9496 return Err(std::io::Error::last_os_error().into());
9497 }
9498 Ok(names)
9499}
9500
9501#[cfg(unix)]
9504fn remove_tree_at(
9505 parent: std::os::fd::RawFd,
9506 name: &std::ffi::CStr,
9507 display: &str,
9508) -> LinkResult<()> {
9509 use std::os::fd::AsRawFd as _;
9510
9511 match entry_is_dir_at(parent, name)? {
9512 None => return Ok(()),
9513 Some(false) => {
9514 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
9515 return Err(std::io::Error::last_os_error().into());
9516 }
9517 }
9518 Some(true) => {
9519 let directory = open_dir_at(parent, name, display)?;
9520 for child in directory_entry_names(&directory)? {
9521 let child_display =
9522 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
9523 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
9524 }
9525 drop(directory);
9526 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
9527 return Err(std::io::Error::last_os_error().into());
9528 }
9529 }
9530 }
9531 Ok(())
9532}
9533
9534#[cfg(unix)]
9538fn clone_tree_contents(
9539 source: &std::fs::File,
9540 destination: &std::fs::File,
9541 display: &str,
9542) -> LinkResult<()> {
9543 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9544
9545 for name in directory_entry_names(source)? {
9546 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9547 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9548 if unsafe {
9549 libc::fstatat(
9550 source.as_raw_fd(),
9551 name.as_ptr(),
9552 &mut stat,
9553 libc::AT_SYMLINK_NOFOLLOW,
9554 )
9555 } != 0
9556 {
9557 return Err(std::io::Error::last_os_error().into());
9558 }
9559 match stat.st_mode & libc::S_IFMT {
9560 libc::S_IFDIR => {
9561 if unsafe {
9562 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
9563 } != 0
9564 {
9565 return Err(std::io::Error::last_os_error().into());
9566 }
9567 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
9568 let destination_child =
9569 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
9570 clone_tree_contents(&source_child, &destination_child, &child_display)?;
9571 destination_child.sync_all()?;
9572 }
9573 libc::S_IFREG => {
9574 let source_fd = unsafe {
9575 libc::openat(
9576 source.as_raw_fd(),
9577 name.as_ptr(),
9578 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9579 )
9580 };
9581 if source_fd < 0 {
9582 return Err(std::io::Error::last_os_error().into());
9583 }
9584 let destination_fd = unsafe {
9585 libc::openat(
9586 destination.as_raw_fd(),
9587 name.as_ptr(),
9588 libc::O_WRONLY
9589 | libc::O_CREAT
9590 | libc::O_EXCL
9591 | libc::O_CLOEXEC
9592 | libc::O_NOFOLLOW,
9593 (stat.st_mode & 0o777) as libc::c_uint,
9594 )
9595 };
9596 if destination_fd < 0 {
9597 unsafe {
9598 libc::close(source_fd);
9599 }
9600 return Err(std::io::Error::last_os_error().into());
9601 }
9602 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
9603 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
9604 std::io::copy(&mut input, &mut output)?;
9605 output.sync_all()?;
9606 }
9607 libc::S_IFLNK => {
9608 let mut target = vec![0_u8; 4097];
9609 let length = unsafe {
9610 libc::readlinkat(
9611 source.as_raw_fd(),
9612 name.as_ptr(),
9613 target.as_mut_ptr().cast(),
9614 target.len(),
9615 )
9616 };
9617 if length < 0 || length as usize >= target.len() {
9618 return Err(LinkError::UnsafePath {
9619 path: child_display,
9620 });
9621 }
9622 target.truncate(length as usize);
9623 let target = c_name(&target, &child_display)?;
9624 if unsafe {
9625 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
9626 } != 0
9627 {
9628 return Err(std::io::Error::last_os_error().into());
9629 }
9630 }
9631 _ => {
9632 return Err(LinkError::UnsafePath {
9633 path: child_display,
9634 });
9635 }
9636 }
9637 }
9638 destination.sync_all()?;
9639 Ok(())
9640}
9641
9642#[cfg(target_os = "linux")]
9643fn install_stage_at(
9644 parent: std::os::fd::RawFd,
9645 stage: &std::ffi::CStr,
9646 dest: &std::ffi::CStr,
9647 dest_exists: bool,
9648) -> LinkResult<()> {
9649 let flags = if dest_exists {
9650 libc::RENAME_EXCHANGE
9651 } else {
9652 libc::RENAME_NOREPLACE
9653 };
9654 let result = unsafe {
9658 libc::syscall(
9659 libc::SYS_renameat2,
9660 parent,
9661 stage.as_ptr(),
9662 parent,
9663 dest.as_ptr(),
9664 flags,
9665 )
9666 };
9667 if result == 0 {
9668 Ok(())
9669 } else {
9670 Err(std::io::Error::last_os_error().into())
9671 }
9672}
9673
9674#[cfg(target_os = "macos")]
9675fn install_stage_at(
9676 parent: std::os::fd::RawFd,
9677 stage: &std::ffi::CStr,
9678 dest: &std::ffi::CStr,
9679 dest_exists: bool,
9680) -> LinkResult<()> {
9681 let flags = if dest_exists {
9682 libc::RENAME_SWAP
9683 } else {
9684 libc::RENAME_EXCL
9685 };
9686 let result =
9687 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
9688 if result == 0 {
9689 Ok(())
9690 } else {
9691 Err(std::io::Error::last_os_error().into())
9692 }
9693}
9694
9695#[cfg(unix)]
9696fn write_pull_entries_beneath_dir(
9697 root: &std::fs::File,
9698 entries: &[(String, Vec<u8>)],
9699) -> LinkResult<()> {
9700 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9701
9702 for (path, content) in entries {
9703 let components: Vec<&str> = path.split('/').collect();
9704 let (leaf, parents) = components
9705 .split_last()
9706 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9707 let mut directory = root.try_clone()?;
9708 for component in parents {
9709 let name = c_name(component.as_bytes(), path)?;
9710 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9711 if made != 0 {
9712 let error = std::io::Error::last_os_error();
9713 if error.raw_os_error() != Some(libc::EEXIST) {
9714 return Err(error.into());
9715 }
9716 }
9717 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9718 }
9719
9720 let leaf_name = c_name(leaf.as_bytes(), path)?;
9721 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9722 let inspected = unsafe {
9723 libc::fstatat(
9724 directory.as_raw_fd(),
9725 leaf_name.as_ptr(),
9726 &mut existing,
9727 libc::AT_SYMLINK_NOFOLLOW,
9728 )
9729 };
9730 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
9731 return Err(LinkError::UnsafePath { path: path.clone() });
9732 }
9733
9734 let nonce = std::time::SystemTime::now()
9735 .duration_since(std::time::UNIX_EPOCH)
9736 .unwrap_or_default()
9737 .as_nanos();
9738 let temp_name = format!(
9739 ".dbmd-pull-{}-{nonce}-{}",
9740 std::process::id(),
9741 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
9742 );
9743 let temp = c_name(temp_name.as_bytes(), path)?;
9744 let fd = unsafe {
9745 libc::openat(
9746 directory.as_raw_fd(),
9747 temp.as_ptr(),
9748 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9749 0o600,
9750 )
9751 };
9752 if fd < 0 {
9753 return Err(std::io::Error::last_os_error().into());
9754 }
9755 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9756 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
9757 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9758 return Err(error.into());
9759 }
9760 drop(file);
9761 let renamed = unsafe {
9762 libc::renameat(
9763 directory.as_raw_fd(),
9764 temp.as_ptr(),
9765 directory.as_raw_fd(),
9766 leaf_name.as_ptr(),
9767 )
9768 };
9769 if renamed != 0 {
9770 let error = std::io::Error::last_os_error();
9771 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9772 return Err(error.into());
9773 }
9774 directory.sync_all()?;
9775 }
9776 root.sync_all()?;
9777 Ok(())
9778}
9779
9780#[cfg(unix)]
9781fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9782 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9783
9784 let path = &entry.path;
9785 let components: Vec<&str> = path.split('/').collect();
9786 let (leaf, parents) = components
9787 .split_last()
9788 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9789 let mut directory = root.try_clone()?;
9790 for component in parents {
9791 let name = c_name(component.as_bytes(), path)?;
9792 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9793 if made != 0 {
9794 let error = std::io::Error::last_os_error();
9795 if error.raw_os_error() != Some(libc::EEXIST) {
9796 return Err(error.into());
9797 }
9798 }
9799 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9800 }
9801 let leaf_name = c_name(leaf.as_bytes(), path)?;
9802 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9803 if unsafe {
9804 libc::fstatat(
9805 directory.as_raw_fd(),
9806 leaf_name.as_ptr(),
9807 &mut existing,
9808 libc::AT_SYMLINK_NOFOLLOW,
9809 )
9810 } == 0
9811 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
9812 {
9813 return Err(LinkError::UnsafePath { path: path.clone() });
9814 }
9815 let nonce = SystemTime::now()
9816 .duration_since(UNIX_EPOCH)
9817 .unwrap_or_default()
9818 .as_nanos();
9819 let temp_name = format!(
9820 ".dbmd-pull-{}-{nonce}-{}",
9821 std::process::id(),
9822 content_sha256(path.as_bytes())
9823 );
9824 let temp = c_name(temp_name.as_bytes(), path)?;
9825 let fd = unsafe {
9826 libc::openat(
9827 directory.as_raw_fd(),
9828 temp.as_ptr(),
9829 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9830 0o600,
9831 )
9832 };
9833 if fd < 0 {
9834 return Err(std::io::Error::last_os_error().into());
9835 }
9836 let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
9837 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
9838 let mut digest = Sha256::new();
9839 let mut total = 0_u64;
9840 let mut buffer = [0_u8; 64 * 1024];
9841 let copied = (|| -> std::io::Result<()> {
9842 loop {
9843 let read = input.read(&mut buffer)?;
9844 if read == 0 {
9845 break;
9846 }
9847 total = total.saturating_add(read as u64);
9848 if total > entry.bytes {
9849 return Err(std::io::Error::new(
9850 std::io::ErrorKind::InvalidData,
9851 "staged sync source grew beyond its verified length",
9852 ));
9853 }
9854 digest.update(&buffer[..read]);
9855 output.write_all(&buffer[..read])?;
9856 }
9857 Ok(())
9858 })();
9859 if let Err(error) = copied {
9860 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9861 return Err(error.into());
9862 }
9863 drop(output);
9864 if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
9865 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9866 return Err(invalid_feed(
9867 "private staged sync source failed final integrity verification",
9868 ));
9869 }
9870 if unsafe {
9871 libc::renameat(
9872 directory.as_raw_fd(),
9873 temp.as_ptr(),
9874 directory.as_raw_fd(),
9875 leaf_name.as_ptr(),
9876 )
9877 } != 0
9878 {
9879 let error = std::io::Error::last_os_error();
9880 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9881 return Err(error.into());
9882 }
9883 Ok(())
9884}
9885
9886#[cfg(unix)]
9887fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9888 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9889
9890 let path = &entry.path;
9891 let components: Vec<&str> = path.split('/').collect();
9892 let (leaf, parents) = components
9893 .split_last()
9894 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9895 let mut directory = root.try_clone()?;
9896 for component in parents {
9897 directory = open_dir_at(
9898 directory.as_raw_fd(),
9899 &c_name(component.as_bytes(), path)?,
9900 path,
9901 )?;
9902 }
9903 let leaf = c_name(leaf.as_bytes(), path)?;
9904 let fd = unsafe {
9905 libc::openat(
9906 directory.as_raw_fd(),
9907 leaf.as_ptr(),
9908 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9909 )
9910 };
9911 if fd < 0 {
9912 return Err(std::io::Error::last_os_error().into());
9913 }
9914 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9915 if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
9916 return Err(invalid_feed(
9917 "private pull stage changed before its durability barrier",
9918 ));
9919 }
9920 file.sync_all()?;
9921 Ok(())
9922}
9923
9924#[cfg(unix)]
9925fn run_pull_source_workers(
9926 root: &std::fs::File,
9927 entries: &[V2StagedFile],
9928 operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
9929) -> LinkResult<()> {
9930 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9931
9932 let next = AtomicUsize::new(0);
9933 let failed = AtomicBool::new(false);
9934 let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
9935 let mut first_error = None;
9936 std::thread::scope(|scope| {
9937 let (sender, receiver) = std::sync::mpsc::channel();
9938 for _ in 0..worker_count {
9939 let sender = sender.clone();
9940 let next = &next;
9941 let failed = &failed;
9942 scope.spawn(move || {
9943 while !failed.load(Ordering::Acquire) {
9944 let index = next.fetch_add(1, Ordering::Relaxed);
9945 let Some(entry) = entries.get(index) else {
9946 break;
9947 };
9948 let result = operation(root, entry);
9949 if result.is_err() {
9950 failed.store(true, Ordering::Release);
9951 }
9952 if sender.send(result).is_err() {
9953 break;
9954 }
9955 }
9956 });
9957 }
9958 drop(sender);
9959 for result in receiver {
9960 if let Err(error) = result {
9961 if first_error.is_none() {
9962 first_error = Some(error);
9963 }
9964 }
9965 }
9966 });
9967 if let Some(error) = first_error {
9968 return Err(error);
9969 }
9970 if next.load(Ordering::Relaxed) < entries.len() {
9971 return Err(invalid_feed(
9972 "a bounded pull worker stopped before reporting every file",
9973 ));
9974 }
9975 Ok(())
9976}
9977
9978#[cfg(unix)]
9979fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9980 use std::os::fd::AsRawFd as _;
9981
9982 for name in directory_entry_names(root)? {
9983 if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9984 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9985 let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9986 sync_pull_directory_tree(&child, &child_display)?;
9987 }
9988 }
9989 root.sync_all()?;
9990 Ok(())
9991}
9992
9993#[cfg(unix)]
9994fn write_pull_sources_beneath_dir(
9995 root: &std::fs::File,
9996 entries: &[V2StagedFile],
9997) -> LinkResult<()> {
9998 run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
10005 run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
10006 sync_pull_directory_tree(root, "v2 pull stage")
10007}
10008
10009#[cfg(unix)]
10010fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
10011 use std::os::fd::AsRawFd as _;
10012 for path in paths {
10013 if !safe_store_rel_path(path) {
10014 return Err(LinkError::UnsafePath { path: path.clone() });
10015 }
10016 let components = path.split('/').collect::<Vec<_>>();
10017 let Some((leaf, parents)) = components.split_last() else {
10018 return Err(LinkError::UnsafePath { path: path.clone() });
10019 };
10020 let mut directory = root.try_clone()?;
10021 let mut missing = false;
10022 for component in parents {
10023 let name = c_name(component.as_bytes(), path)?;
10024 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
10025 None => {
10026 missing = true;
10027 break;
10028 }
10029 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
10030 Some(true) => {
10031 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10032 }
10033 }
10034 }
10035 if missing {
10036 continue;
10037 }
10038 let leaf = c_name(leaf.as_bytes(), path)?;
10039 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
10040 None => {}
10041 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
10042 Some(false) => {
10043 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
10044 return Err(std::io::Error::last_os_error().into());
10045 }
10046 directory.sync_all()?;
10047 }
10048 }
10049 }
10050 Ok(())
10051}
10052
10053#[cfg(unix)]
10054fn install_pulled_delta(
10055 dest: &Path,
10056 entries: &[(String, Vec<u8>)],
10057 deleted: &[String],
10058 rebuild_indexes: bool,
10059) -> LinkResult<()> {
10060 use ring::rand::SecureRandom as _;
10061 use std::os::fd::AsRawFd as _;
10062 use std::os::unix::ffi::OsStrExt as _;
10063
10064 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10065 let name = dest
10066 .file_name()
10067 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10068 .ok_or_else(|| LinkError::UnsafePath {
10069 path: dest.display().to_string(),
10070 })?;
10071 let parent_dir = open_or_create_dir_nofollow(parent)?;
10072 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10073 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10074 None => false,
10075 Some(true) => true,
10076 Some(false) => {
10077 return Err(LinkError::UnsafePath {
10078 path: dest.display().to_string(),
10079 });
10080 }
10081 };
10082
10083 let mut nonce = [0_u8; 16];
10084 ring::rand::SystemRandom::new()
10085 .fill(&mut nonce)
10086 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10087 let stage_label = format!(
10088 ".{}.dbmd-pull-stage-{}",
10089 name.to_string_lossy(),
10090 URL_SAFE_NO_PAD.encode(nonce)
10091 );
10092 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10093 let stage_dir = create_dir_exclusive_at(
10094 parent_dir.as_raw_fd(),
10095 &stage_name,
10096 &dest.display().to_string(),
10097 )?;
10098
10099 let prepared = (|| -> LinkResult<()> {
10100 if dest_exists {
10101 let live = open_dir_at(
10102 parent_dir.as_raw_fd(),
10103 &dest_name,
10104 &dest.display().to_string(),
10105 )?;
10106 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10107 }
10108 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10109 write_pull_entries_beneath_dir(&stage_dir, entries)?;
10110 if rebuild_indexes {
10111 let stage_store =
10112 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10113 .map_err(|error| LinkError::InvalidPack {
10114 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10115 })?;
10116 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10117 LinkError::InvalidPack {
10118 message: format!("could not materialize v2 local catalogs: {error}"),
10119 }
10120 })?;
10121 }
10122 stage_dir.sync_all()?;
10123 Ok(())
10124 })();
10125 if let Err(error) = prepared {
10126 let _ = remove_tree_at(
10127 parent_dir.as_raw_fd(),
10128 &stage_name,
10129 &dest.display().to_string(),
10130 );
10131 return Err(error);
10132 }
10133
10134 if let Err(error) =
10135 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10136 {
10137 let _ = remove_tree_at(
10138 parent_dir.as_raw_fd(),
10139 &stage_name,
10140 &dest.display().to_string(),
10141 );
10142 return Err(error);
10143 }
10144 parent_dir.sync_all()?;
10145 if dest_exists {
10146 let _ = remove_tree_at(
10150 parent_dir.as_raw_fd(),
10151 &stage_name,
10152 &dest.display().to_string(),
10153 );
10154 let _ = parent_dir.sync_all();
10155 }
10156 Ok(())
10157}
10158
10159#[cfg(unix)]
10160fn install_pulled_delta_sources(
10161 dest: &Path,
10162 entries: &[V2StagedFile],
10163 deleted: &[String],
10164 rebuild_indexes: bool,
10165 _previous: Option<&V2SyncBaseline>,
10166 _next: &V2VerifiedHead,
10167) -> LinkResult<()> {
10168 use ring::rand::SecureRandom as _;
10169 use std::os::fd::AsRawFd as _;
10170 use std::os::unix::ffi::OsStrExt as _;
10171
10172 if let Ok(store) = Store::open_strict(dest) {
10176 return install_established_v2_delta(
10177 store,
10178 entries,
10179 deleted,
10180 rebuild_indexes,
10181 _previous,
10182 _next,
10183 );
10184 }
10185
10186 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10187 let name = dest
10188 .file_name()
10189 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10190 .ok_or_else(|| LinkError::UnsafePath {
10191 path: dest.display().to_string(),
10192 })?;
10193 let parent_dir = open_or_create_dir_nofollow(parent)?;
10194 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10195 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10196 None => false,
10197 Some(true) => true,
10198 Some(false) => {
10199 return Err(LinkError::UnsafePath {
10200 path: dest.display().to_string(),
10201 })
10202 }
10203 };
10204 let mut nonce = [0_u8; 16];
10205 ring::rand::SystemRandom::new()
10206 .fill(&mut nonce)
10207 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10208 let stage_label = format!(
10209 ".{}.dbmd-pull-stage-{}",
10210 name.to_string_lossy(),
10211 URL_SAFE_NO_PAD.encode(nonce)
10212 );
10213 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10214 let stage_dir = create_dir_exclusive_at(
10215 parent_dir.as_raw_fd(),
10216 &stage_name,
10217 &dest.display().to_string(),
10218 )?;
10219 let prepared = (|| -> LinkResult<()> {
10220 if dest_exists {
10221 let live = open_dir_at(
10222 parent_dir.as_raw_fd(),
10223 &dest_name,
10224 &dest.display().to_string(),
10225 )?;
10226 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10227 }
10228 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10229 write_pull_sources_beneath_dir(&stage_dir, entries)?;
10230 if rebuild_indexes {
10231 let stage_store =
10232 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10233 .map_err(|error| LinkError::InvalidPack {
10234 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10235 })?;
10236 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10237 LinkError::InvalidPack {
10238 message: format!("could not materialize v2 local catalogs: {error}"),
10239 }
10240 })?;
10241 }
10242 stage_dir.sync_all()?;
10243 Ok(())
10244 })();
10245 if let Err(error) = prepared {
10246 let _ = remove_tree_at(
10247 parent_dir.as_raw_fd(),
10248 &stage_name,
10249 &dest.display().to_string(),
10250 );
10251 return Err(error);
10252 }
10253 if let Err(error) =
10254 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10255 {
10256 let _ = remove_tree_at(
10257 parent_dir.as_raw_fd(),
10258 &stage_name,
10259 &dest.display().to_string(),
10260 );
10261 return Err(error);
10262 }
10263 parent_dir.sync_all()?;
10264 if dest_exists {
10265 let _ = remove_tree_at(
10266 parent_dir.as_raw_fd(),
10267 &stage_name,
10268 &dest.display().to_string(),
10269 );
10270 let _ = parent_dir.sync_all();
10271 }
10272 Ok(())
10273}
10274
10275#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10276struct V2PullCoordinate {
10277 head_seq: Option<u64>,
10278 commit_hash: Option<String>,
10279 view_kind: Option<String>,
10280 view_revision: Option<String>,
10281}
10282
10283#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10284struct V2PullFileCoordinate {
10285 sha256: String,
10286 bytes: u64,
10287}
10288
10289#[derive(Debug, Clone, Deserialize, Serialize)]
10290struct V2PullJournalEntry {
10291 path: String,
10292 old: Option<V2PullFileCoordinate>,
10293 new: Option<V2PullFileCoordinate>,
10294 backup: Option<String>,
10295}
10296
10297#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10298#[serde(rename_all = "snake_case")]
10299enum V2PullPhase {
10300 Preparing,
10301 Ready,
10302}
10303
10304#[derive(Debug, Clone, Deserialize, Serialize)]
10305struct V2PullJournal {
10306 v: u8,
10307 phase: V2PullPhase,
10308 brain: String,
10309 previous: V2PullCoordinate,
10310 next: V2PullCoordinate,
10311 backup_dir: String,
10312 entries: Vec<V2PullJournalEntry>,
10313}
10314
10315const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
10316
10317fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
10318 V2PullCoordinate {
10319 head_seq: baseline.and_then(|value| value.head_seq),
10320 commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
10321 view_kind: baseline.and_then(|value| value.view_kind.clone()),
10322 view_revision: baseline.and_then(|value| value.view_revision.clone()),
10323 }
10324}
10325
10326fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
10327 V2PullCoordinate {
10328 head_seq: head.pointer.as_ref().map(|value| value.seq),
10329 commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
10330 view_kind: Some(head.view_kind.clone()),
10331 view_revision: Some(head.view_revision.clone()),
10332 }
10333}
10334
10335fn v2_pull_file_coordinate(
10336 store: &Store,
10337 path: &str,
10338 limit: u64,
10339) -> LinkResult<Option<V2PullFileCoordinate>> {
10340 let file = match store.open_regular(Path::new(path)) {
10341 Ok(file) => file,
10342 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10343 Err(error) => return Err(error.into()),
10344 };
10345 let bytes = file.metadata()?.len();
10346 if bytes > limit || bytes > MAX_STORE_BYTES {
10347 return Err(invalid_feed(
10348 "pull transaction file exceeds its declared bound",
10349 ));
10350 }
10351 Ok(Some(V2PullFileCoordinate {
10352 sha256: content_sha256_reader(file)?,
10353 bytes,
10354 }))
10355}
10356
10357fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
10358 let mut bytes = serde_json::to_vec_pretty(journal)
10359 .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
10360 bytes.push(b'\n');
10361 Ok(bytes)
10362}
10363
10364fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
10365 let backup_prefix = ".dbmd/pull-backup-";
10366 let suffix = journal
10367 .backup_dir
10368 .strip_prefix(backup_prefix)
10369 .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
10370 let mut paths = std::collections::BTreeSet::new();
10371 if journal.v != 1
10372 || !crate::ulid::is_ulid(&journal.brain)
10373 || !crate::ulid::is_ulid(suffix)
10374 || journal.entries.is_empty()
10375 || journal.entries.len() > MAX_PUSH_FILES + 4
10376 || journal.previous == journal.next
10377 {
10378 return Err(invalid_feed("v2 pull journal failed validation"));
10379 }
10380 for (index, entry) in journal.entries.iter().enumerate() {
10381 if !safe_store_rel_path(&entry.path)
10382 || entry.path == V2_PULL_JOURNAL
10383 || entry.path.starts_with(backup_prefix)
10384 || !paths.insert(entry.path.clone())
10385 || (entry.old.is_none() && entry.new.is_none())
10386 || entry
10387 .old
10388 .iter()
10389 .chain(entry.new.iter())
10390 .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
10391 || entry.backup.as_deref()
10392 != entry
10393 .old
10394 .as_ref()
10395 .map(|_| format!("{index:08x}"))
10396 .as_deref()
10397 {
10398 return Err(invalid_feed("v2 pull journal entry failed validation"));
10399 }
10400 }
10401 Ok(())
10402}
10403
10404fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
10405 #[cfg(unix)]
10406 {
10407 use std::os::unix::fs::PermissionsExt as _;
10408 match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
10409 Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
10410 return Err(invalid_feed(
10411 "v2 pull journal is accessible to group/other; set mode 0600",
10412 ));
10413 }
10414 Ok(_) => {}
10415 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10416 Err(error) => return Err(error.into()),
10417 }
10418 }
10419 let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
10420 Ok(bytes) => bytes,
10421 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10422 Err(error) => return Err(error.into()),
10423 };
10424 let journal: V2PullJournal =
10425 serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
10426 validate_v2_pull_journal(&journal)?;
10427 Ok(Some(journal))
10428}
10429
10430fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10431 match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
10435 Ok(()) => {}
10436 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
10437 Err(error) => return Err(error.into()),
10438 }
10439 match store.remove_private_tree(Path::new(&journal.backup_dir)) {
10440 Ok(()) => Ok(()),
10441 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
10442 Err(error) => Err(error.into()),
10443 }
10444}
10445
10446fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
10447 let names = match store.directory_names(Path::new(".dbmd")) {
10448 Ok(names) => names,
10449 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
10450 Err(error) => return Err(error.into()),
10451 };
10452 for name in names {
10453 let Some(name) = name.to_str() else {
10454 continue;
10455 };
10456 let Some(suffix) = name.strip_prefix("pull-backup-") else {
10457 continue;
10458 };
10459 if crate::ulid::is_ulid(suffix) {
10460 store.remove_private_tree(&Path::new(".dbmd").join(name))?;
10461 }
10462 }
10463 Ok(())
10464}
10465
10466fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10467 for entry in &journal.entries {
10469 let limit = entry
10470 .old
10471 .as_ref()
10472 .into_iter()
10473 .chain(entry.new.iter())
10474 .map(|value| value.bytes)
10475 .max()
10476 .unwrap_or(0);
10477 let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
10478 if current != entry.old && current != entry.new {
10479 return Err(LinkError::InvalidPack {
10480 message: format!(
10481 "cannot recover interrupted pull because `{}` changed afterward",
10482 entry.path
10483 ),
10484 });
10485 }
10486 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10487 let path = Path::new(&journal.backup_dir).join(backup);
10488 let file = store.open_regular(&path)?;
10489 if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
10490 return Err(invalid_feed("v2 pull recovery backup failed verification"));
10491 }
10492 }
10493 }
10494 for entry in journal.entries.iter().rev() {
10495 match (&entry.old, &entry.backup) {
10496 (Some(old), Some(backup)) => {
10497 let bytes =
10498 store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
10499 store.write_atomic(Path::new(&entry.path), &bytes)?;
10500 }
10501 (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
10502 store.remove_file(Path::new(&entry.path))?;
10503 }
10504 (None, None) => {}
10505 _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
10506 }
10507 }
10508 crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
10509 message: format!("could not rebuild catalogs after pull recovery: {error}"),
10510 })?;
10511 cleanup_v2_pull_journal(store, journal)
10512}
10513
10514fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
10515 let Ok(store) = Store::open_strict(dest) else {
10516 return Ok(());
10517 };
10518 if let Some(journal) = load_v2_pull_journal(&store)? {
10519 if journal.brain != brain {
10520 return Err(invalid_feed("v2 pull journal belongs to another brain"));
10521 }
10522 if journal.phase == V2PullPhase::Preparing {
10523 cleanup_v2_pull_journal(&store, &journal)?;
10524 } else {
10525 let baseline = load_v2_baseline(cfg, brain, dest)?;
10526 let current = v2_pull_baseline_coordinate(baseline.as_ref());
10527 if current == journal.next {
10528 cleanup_v2_pull_journal(&store, &journal)?;
10529 } else {
10530 if current != journal.previous {
10531 return Err(invalid_feed(
10532 "cannot recover interrupted pull because its baseline changed afterward",
10533 ));
10534 }
10535 rollback_v2_pull(&store, &journal)?;
10536 }
10537 }
10538 }
10539 prune_orphan_v2_pull_backups(&store)
10544}
10545
10546fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
10547 let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
10548 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
10549 })?;
10550 if let Some(journal) = load_v2_pull_journal(&store)? {
10551 cleanup_v2_pull_journal(&store, &journal)?;
10552 }
10553 Ok(())
10554}
10555
10556#[cfg(windows)]
10557fn install_windows_initial_sources(
10558 dest: &Path,
10559 entries: &[V2StagedFile],
10560 rebuild_indexes: bool,
10561) -> LinkResult<()> {
10562 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10563 let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
10564 path: dest.display().to_string(),
10565 })?;
10566 let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
10567 if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
10568 return Err(LinkError::UnsafePath {
10569 path: dest.display().to_string(),
10570 });
10571 }
10572 let stage_name = format!(
10573 ".{}.dbmd-pull-stage-{}",
10574 name.to_string_lossy(),
10575 crate::ulid::mint()
10576 );
10577 let stage_path = parent.join(&stage_name);
10578 let stage_capability =
10579 crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
10580 let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
10581 let prepared = (|| -> LinkResult<()> {
10582 for entry in entries {
10583 let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
10584 if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
10585 return Err(invalid_feed(
10586 "private staged sync source failed final integrity verification",
10587 ));
10588 }
10589 stage.write_atomic(Path::new(&entry.path), &bytes)?;
10590 }
10591 let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
10592 .map_err(|error| LinkError::InvalidPack {
10593 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10594 })?;
10595 if rebuild_indexes {
10596 crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
10597 message: format!("could not materialize v2 local catalogs: {error}"),
10598 })?;
10599 }
10600 Ok(())
10601 })();
10602 if let Err(error) = prepared {
10603 let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
10604 return Err(error);
10605 }
10606 crate::fsx::rename_directory_beneath(
10607 &parent_capability,
10608 Path::new(&stage_name),
10609 Path::new(name),
10610 )?;
10611 Ok(())
10612}
10613
10614fn install_established_v2_delta(
10615 store: Store,
10616 entries: &[V2StagedFile],
10617 deleted: &[String],
10618 rebuild_indexes: bool,
10619 previous: Option<&V2SyncBaseline>,
10620 next: &V2VerifiedHead,
10621) -> LinkResult<()> {
10622 if load_v2_pull_journal(&store)?.is_some() {
10623 return Err(invalid_feed(
10624 "an interrupted pull must be recovered before installing",
10625 ));
10626 }
10627 let mut sources = std::collections::BTreeMap::new();
10628 for entry in entries {
10629 if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
10630 return Err(invalid_feed("pull mutation repeats a path"));
10631 }
10632 }
10633 let mut paths = sources.keys().cloned().collect::<Vec<_>>();
10634 paths.extend(deleted.iter().cloned());
10635 paths.sort();
10636 paths.dedup();
10637 if paths.is_empty() {
10638 return Ok(());
10639 }
10640 let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
10641 let mut journal = V2PullJournal {
10642 v: 1,
10643 phase: V2PullPhase::Preparing,
10644 brain: next.brain_id.clone(),
10645 previous: v2_pull_baseline_coordinate(previous),
10646 next: v2_pull_head_coordinate(next),
10647 backup_dir: backup_dir.clone(),
10648 entries: Vec::with_capacity(paths.len()),
10649 };
10650 for path in &paths {
10651 let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
10652 let new = sources.get(path).map(|entry| V2PullFileCoordinate {
10653 sha256: entry.sha256.clone(),
10654 bytes: entry.bytes,
10655 });
10656 if old == new {
10657 continue;
10658 }
10659 let index = journal.entries.len();
10660 journal.entries.push(V2PullJournalEntry {
10661 path: path.clone(),
10662 backup: old.as_ref().map(|_| format!("{index:08x}")),
10663 old,
10664 new,
10665 });
10666 }
10667 if journal.entries.is_empty() {
10668 return Ok(());
10669 }
10670 let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
10671 entry
10672 .old
10673 .as_ref()
10674 .map_or(Some(total), |old| total.checked_add(old.bytes))
10675 });
10676 if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
10677 return Err(LinkError::InvalidPack {
10678 message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
10679 });
10680 }
10681 validate_v2_pull_journal(&journal)?;
10682 store.write_private_atomic_new(
10683 Path::new(V2_PULL_JOURNAL),
10684 &v2_pull_journal_bytes(&journal)?,
10685 )?;
10686 let prepared = (|| -> LinkResult<()> {
10687 store.create_private_dir_all(Path::new(&backup_dir))?;
10688 for entry in &journal.entries {
10689 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10690 let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
10691 if content_sha256(&bytes) != old.sha256 {
10692 return Err(invalid_feed("live pull source changed during backup"));
10693 }
10694 store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
10695 }
10696 }
10697 journal.phase = V2PullPhase::Ready;
10698 store.write_private_atomic(
10699 Path::new(V2_PULL_JOURNAL),
10700 &v2_pull_journal_bytes(&journal)?,
10701 )?;
10702 Ok(())
10703 })();
10704 if let Err(error) = prepared {
10705 let cleanup = cleanup_v2_pull_journal(&store, &journal);
10706 return match cleanup {
10707 Ok(()) => Err(error),
10708 Err(cleanup) => Err(LinkError::InvalidPack {
10709 message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
10710 }),
10711 };
10712 }
10713 let installed = (|| -> LinkResult<()> {
10714 for entry in &journal.entries {
10715 if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
10716 return Err(LinkError::InvalidPack {
10717 message: format!("local path `{}` changed during pull", entry.path),
10718 });
10719 }
10720 if let Some(source) = sources.get(&entry.path) {
10721 let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
10722 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
10723 return Err(invalid_feed(
10724 "private staged sync source failed final integrity verification",
10725 ));
10726 }
10727 store.write_atomic(Path::new(&entry.path), &bytes)?;
10728 } else if entry.old.is_some() {
10729 store.remove_file(Path::new(&entry.path))?;
10730 }
10731 }
10732 if rebuild_indexes {
10733 crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
10734 message: format!("could not materialize v2 local catalogs: {error}"),
10735 })?;
10736 }
10737 Ok(())
10738 })();
10739 if let Err(error) = installed {
10740 return match rollback_v2_pull(&store, &journal) {
10741 Ok(()) => Err(error),
10742 Err(rollback) => Err(LinkError::InvalidPack {
10743 message: format!("{error}; durable pull rollback also failed: {rollback}"),
10744 }),
10745 };
10746 }
10747 Ok(())
10748}
10749
10750#[cfg(windows)]
10751fn install_pulled_delta_sources(
10752 dest: &Path,
10753 entries: &[V2StagedFile],
10754 deleted: &[String],
10755 rebuild_indexes: bool,
10756 previous: Option<&V2SyncBaseline>,
10757 next: &V2VerifiedHead,
10758) -> LinkResult<()> {
10759 match Store::open_strict(dest) {
10760 Ok(store) => {
10761 install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
10762 }
10763 Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
10764 }
10765}
10766
10767#[cfg(not(any(unix, windows)))]
10768fn install_pulled_delta_sources(
10769 _dest: &Path,
10770 _entries: &[V2StagedFile],
10771 _deleted: &[String],
10772 _rebuild_indexes: bool,
10773 _previous: Option<&V2SyncBaseline>,
10774 _next: &V2VerifiedHead,
10775) -> LinkResult<()> {
10776 Err(LinkError::UnsupportedPlatform {
10777 operation: "atomic v2 pull install",
10778 })
10779}
10780
10781#[cfg(unix)]
10782fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
10783 install_pulled_delta(dest, entries, &[], false)
10784}
10785
10786#[cfg(not(windows))]
10787fn is_safe_slug(slug: &str) -> bool {
10788 !slug.is_empty()
10789 && slug.len() <= 63
10790 && !slug.starts_with('-')
10791 && !slug.ends_with('-')
10792 && slug
10793 .bytes()
10794 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
10795}
10796
10797fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
10798 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
10799}
10800
10801fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
10802 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
10803}
10804
10805fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
10806 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
10807}
10808
10809fn preflight_zip_central_directory(
10810 bytes: &[u8],
10811 offset: usize,
10812 size: usize,
10813 count: u64,
10814) -> LinkResult<()> {
10815 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
10816 let end = offset
10817 .checked_add(size)
10818 .filter(|end| *end <= bytes.len())
10819 .ok_or_else(|| LinkError::InvalidPack {
10820 message: "ZIP central directory is out of bounds".to_string(),
10821 })?;
10822 let mut cursor = offset;
10823 for _ in 0..count {
10824 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
10825 return Err(LinkError::InvalidPack {
10826 message: "ZIP central directory entry count is inconsistent".to_string(),
10827 });
10828 }
10829 if le_u16(bytes, cursor + 34) != Some(0) {
10830 return Err(LinkError::InvalidPack {
10831 message: "multi-disk ZIP archives are not supported".to_string(),
10832 });
10833 }
10834 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
10835 total.checked_add(le_u16(bytes, cursor + at)? as usize)
10836 });
10837 cursor = cursor
10838 .checked_add(46)
10839 .and_then(|fixed| fixed.checked_add(variable?))
10840 .filter(|cursor| *cursor <= end)
10841 .ok_or_else(|| LinkError::InvalidPack {
10842 message: "ZIP central directory entry is truncated".to_string(),
10843 })?;
10844 }
10845 if cursor != end {
10846 return Err(LinkError::InvalidPack {
10847 message: "ZIP central directory size is inconsistent".to_string(),
10848 });
10849 }
10850 Ok(())
10851}
10852
10853fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
10857 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
10858 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
10859 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
10860 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
10861 let eocd = bytes[search_start..]
10862 .windows(4)
10863 .rposition(|window| window == EOCD_SIG)
10864 .map(|offset| search_start + offset)
10865 .ok_or_else(|| LinkError::InvalidPack {
10866 message: "ZIP has no end-of-central-directory record".to_string(),
10867 })?;
10868 let invalid_end = || LinkError::InvalidPack {
10869 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
10870 };
10871 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
10872 if eocd
10873 .checked_add(22)
10874 .and_then(|end| end.checked_add(comment_len))
10875 != Some(bytes.len())
10876 {
10877 return Err(invalid_end());
10881 }
10882 let disk = le_u16(bytes, eocd + 4);
10883 let central_disk = le_u16(bytes, eocd + 6);
10884 if disk != Some(0) || central_disk != Some(0) {
10885 return Err(LinkError::InvalidPack {
10886 message: "multi-disk ZIP archives are not supported".to_string(),
10887 });
10888 }
10889 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
10890 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
10891 if entries_on_disk != ordinary {
10892 return Err(LinkError::InvalidPack {
10893 message: "multi-disk ZIP archives are not supported".to_string(),
10894 });
10895 }
10896 let zip64_locator = eocd
10897 .checked_sub(20)
10898 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
10899 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
10900 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
10901 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
10902 if central_offset
10903 .checked_add(central_size)
10904 .filter(|end| *end == eocd)
10905 .is_none()
10906 {
10907 return Err(invalid_end());
10908 }
10909 (ordinary as u64, central_offset, central_size)
10910 } else {
10911 let Some(locator) = zip64_locator else {
10912 return Err(invalid_end());
10913 };
10914 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
10915 return Err(LinkError::InvalidPack {
10916 message: "multi-disk ZIP64 archives are not supported".to_string(),
10917 });
10918 }
10919 let record = le_u64(bytes, locator + 8)
10920 .and_then(|offset| usize::try_from(offset).ok())
10921 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
10922 .ok_or_else(|| LinkError::InvalidPack {
10923 message: "ZIP64 archive has an invalid end record".to_string(),
10924 })?;
10925 let record_size = le_u64(bytes, record + 4)
10926 .and_then(|size| usize::try_from(size).ok())
10927 .filter(|size| *size >= 44)
10928 .ok_or_else(invalid_end)?;
10929 if record
10930 .checked_add(12)
10931 .and_then(|end| end.checked_add(record_size))
10932 != Some(locator)
10933 || le_u32(bytes, record + 16) != Some(0)
10934 || le_u32(bytes, record + 20) != Some(0)
10935 {
10936 return Err(invalid_end());
10937 }
10938 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
10939 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
10940 let central_size = le_u64(bytes, record + 40)
10941 .and_then(|size| usize::try_from(size).ok())
10942 .ok_or_else(invalid_end)?;
10943 let central_offset = le_u64(bytes, record + 48)
10944 .and_then(|offset| usize::try_from(offset).ok())
10945 .ok_or_else(invalid_end)?;
10946 if zip64_on_disk != zip64_total
10947 || central_offset
10948 .checked_add(central_size)
10949 .filter(|end| *end == record)
10950 .is_none()
10951 {
10952 return Err(invalid_end());
10953 }
10954 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10955 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10956 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10957 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10958 {
10959 return Err(invalid_end());
10960 }
10961 (zip64_total, central_offset, central_size)
10962 };
10963 if count == 0 || count > max_entries as u64 {
10964 return Err(LinkError::InvalidPack {
10965 message: format!("invalid file count {count}"),
10966 });
10967 }
10968 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10969 Ok(())
10970}
10971
10972fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10973 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10974 let mut archive =
10975 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10976 message: format!("ZIP parse failed: {err}"),
10977 })?;
10978 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10979 return Err(LinkError::InvalidPack {
10980 message: format!("invalid file count {}", archive.len()),
10981 });
10982 }
10983 let mut total = 0u64;
10984 let mut seen = std::collections::HashSet::new();
10985 let mut entries = Vec::with_capacity(archive.len());
10986 for index in 0..archive.len() {
10987 let mut file = archive
10988 .by_index(index)
10989 .map_err(|err| LinkError::InvalidPack {
10990 message: format!("ZIP entry failed: {err}"),
10991 })?;
10992 if file.is_dir() {
10993 continue;
10994 }
10995 let path = file.name().to_string();
10996 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10997 return Err(LinkError::UnsafePath { path });
10998 }
10999 if file
11000 .unix_mode()
11001 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
11002 {
11003 return Err(LinkError::InvalidPack {
11004 message: format!("non-file entry `{path}`"),
11005 });
11006 }
11007 if !seen.insert(path.clone()) {
11008 return Err(LinkError::InvalidPack {
11009 message: format!("duplicate path `{path}`"),
11010 });
11011 }
11012 let remaining = MAX_STORE_BYTES.saturating_sub(total);
11013 if file.size() > remaining {
11014 return Err(LinkError::InvalidPack {
11015 message: "expanded content exceeds the 512 MB limit".to_string(),
11016 });
11017 }
11018 let mut content = Vec::new();
11019 (&mut file)
11020 .take(remaining + 1)
11021 .read_to_end(&mut content)
11022 .map_err(|err| LinkError::InvalidPack {
11023 message: format!("could not decompress `{path}`: {err}"),
11024 })?;
11025 if content.len() as u64 > remaining {
11026 return Err(LinkError::InvalidPack {
11027 message: "expanded content exceeds the 512 MB limit".to_string(),
11028 });
11029 }
11030 if content.len() as u64 != file.size() {
11031 return Err(LinkError::InvalidPack {
11032 message: format!("length mismatch for `{path}`"),
11033 });
11034 }
11035 total += content.len() as u64;
11036 entries.push((path, content));
11037 }
11038 if entries.is_empty() {
11039 return Err(LinkError::InvalidPack {
11040 message: "pack contains no files".to_string(),
11041 });
11042 }
11043 Ok(entries)
11044}
11045
11046fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
11047 let mut expected = std::collections::BTreeMap::new();
11048 for file in signed {
11049 if !safe_store_rel_path(&file.path) {
11050 return Err(LinkError::UnsafePath {
11051 path: file.path.clone(),
11052 });
11053 }
11054 if !is_sha256(&file.sha256)
11055 || expected
11056 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11057 .is_some()
11058 {
11059 return Err(invalid_feed(
11060 "signed snapshot manifest contains an invalid or duplicate file",
11061 ));
11062 }
11063 }
11064 if expected.len() != entries.len() {
11065 return Err(invalid_feed(
11066 "downloaded pack file set differs from the signed snapshot manifest",
11067 ));
11068 }
11069 for (path, bytes) in entries {
11070 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
11071 return Err(invalid_feed(format!(
11072 "downloaded pack contains unsigned path `{path}`"
11073 )));
11074 };
11075 if *declared_bytes != bytes.len() as u64
11076 || *sha256 != format!("{:x}", Sha256::digest(bytes))
11077 {
11078 return Err(invalid_feed(format!(
11079 "downloaded file `{path}` differs from its signed manifest"
11080 )));
11081 }
11082 }
11083 Ok(())
11084}
11085
11086pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
11093 require_hardened_filesystem("sync push")?;
11094 preflight_push_ownership(store)?;
11095 let mut out: Vec<(String, String)> = Vec::new();
11096 let mut total = 0u64;
11097
11098 let mut read_text = |rel: &str| -> LinkResult<String> {
11099 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
11100 total = total
11101 .checked_add(bytes.len() as u64)
11102 .ok_or_else(|| LinkError::PushTooLarge {
11103 detail: "uncompressed byte count overflow".to_string(),
11104 })?;
11105 if total > MAX_STORE_BYTES {
11106 return Err(LinkError::PushTooLarge {
11107 detail: format!("{total} uncompressed bytes"),
11108 });
11109 }
11110 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
11111 path: rel.to_string(),
11112 })
11113 };
11114
11115 out.push(("DB.md".to_string(), read_text("DB.md")?));
11116 if store
11117 .regular_file_exists(Path::new("assets.jsonl"))
11118 .unwrap_or(false)
11119 {
11120 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
11121 }
11122
11123 for rel in store.walk()? {
11124 let rel_str = rel.to_string_lossy().replace('\\', "/");
11125 if !safe_store_rel_path(&rel_str) {
11126 return Err(LinkError::UnsafePath { path: rel_str });
11129 }
11130 let content = read_text(&rel_str)?;
11131 out.push((rel_str, content));
11132 }
11133
11134 out.sort_by(|a, b| a.0.cmp(&b.0));
11135 Ok(out)
11136}
11137
11138fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
11142 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
11143 return Err(LinkError::from(std::io::Error::new(
11144 std::io::ErrorKind::PermissionDenied,
11145 format!("cannot push: nested db.md store at {}", nested.display()),
11146 )));
11147 }
11148
11149 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
11150 return Err(LinkError::from(std::io::Error::new(
11151 std::io::ErrorKind::PermissionDenied,
11152 format!(
11153 "cannot push: {} is a symlink outside the store ownership model",
11154 symlink.display()
11155 ),
11156 )));
11157 }
11158 Ok(())
11159}
11160
11161pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
11167 require_safe_ref(brain)?;
11168 let remote = verified_remote_head(cfg, brain, false)?;
11169 if files.len() > MAX_PUSH_FILES {
11170 return Err(LinkError::PushTooLarge {
11171 detail: format!("{} files", files.len()),
11172 });
11173 }
11174 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
11175 if raw_total > MAX_STORE_BYTES {
11176 return Err(LinkError::PushTooLarge {
11177 detail: format!("{raw_total} uncompressed bytes"),
11178 });
11179 }
11180
11181 if cfg.brain_key.is_none() {
11185 let body = json!({
11186 "files": files
11187 .iter()
11188 .map(|(p, c)| json!({ "path": p, "content": c }))
11189 .collect::<Vec<_>>(),
11190 });
11191 if body.to_string().len() <= MAX_PUSH_BYTES {
11192 let path = format!("/api/hub/brains/{brain}/push");
11193 let pushed = ensure_ok(
11194 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11195 "sync push",
11196 )?;
11197 return Ok(pushed);
11198 }
11199 }
11200
11201 let pack = build_store_pack(files)?;
11202 if pack.len() as u64 > MAX_PACK_BYTES {
11203 return Err(LinkError::PushTooLarge {
11204 detail: format!("{} pack bytes", pack.len()),
11205 });
11206 }
11207 let sha256 = format!("{:x}", Sha256::digest(&pack));
11208 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
11209 if let Some(key) = &cfg.brain_key {
11210 if !remote.head.verified {
11211 return Err(invalid_feed(
11212 "self-custody push requires a fully verified, unscoped feed head",
11213 ));
11214 }
11215 let identity = remote
11216 .identity
11217 .as_ref()
11218 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
11219 let current_multikey = format!("ed25519:{}", identity.fingerprint);
11220 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
11221 return Err(invalid_feed(
11222 "configured brain key is not the verified current brain identity",
11223 ));
11224 }
11225 let next_seq = remote
11228 .head
11229 .seq
11230 .checked_add(1)
11231 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
11232 let mut manifest: Vec<WireFeedFile> = files
11233 .iter()
11234 .map(|(path, content)| WireFeedFile {
11235 path: path.clone(),
11236 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
11237 bytes: content.len() as u64,
11238 })
11239 .collect();
11240 manifest.sort_by(|a, b| a.path.cmp(&b.path));
11241 let ts = crate::now()
11242 .with_timezone(&chrono::Utc)
11243 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
11244 .to_string();
11245 let entry = self_custody_entry(
11246 key,
11247 next_seq,
11248 ts,
11249 &sha256,
11250 &manifest,
11251 remote.head.feed_hash.as_deref(),
11252 )?;
11253 meta["entry"] = Value::String(entry);
11254 }
11255 let presigned = ensure_ok(
11256 request(
11257 cfg,
11258 "POST",
11259 &format!("/api/hub/brains/{brain}/packs/presign"),
11260 Some(&meta),
11261 Auth::Required,
11262 )?,
11263 "prepare pack upload",
11264 )?;
11265 let url = presigned
11266 .get("url")
11267 .and_then(Value::as_str)
11268 .ok_or_else(|| LinkError::InvalidPack {
11269 message: "the hub returned no upload URL".to_string(),
11270 })?;
11271 put_presigned(
11272 cfg,
11273 url,
11274 presigned.get("headers").unwrap_or(&Value::Null),
11275 &pack,
11276 )?;
11277 let committed = ensure_ok(
11278 request(
11279 cfg,
11280 "POST",
11281 &format!("/api/hub/brains/{brain}/packs/commit"),
11282 Some(&meta),
11283 Auth::Required,
11284 )?,
11285 "commit pack",
11286 )?;
11287 Ok(committed)
11288}
11289
11290fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
11291 const LOCAL_HEADER: u32 = 0x0403_4b50;
11292 const CENTRAL_HEADER: u32 = 0x0201_4b50;
11293 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
11294 const VERSION_20: u16 = 20;
11295 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
11296 const UTF8_FLAG: u16 = 1 << 11;
11297 const STORED: u16 = 0;
11298 const DOS_TIME_MIDNIGHT: u16 = 0;
11299 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
11300 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
11301
11302 struct CentralEntry<'a> {
11303 name: &'a [u8],
11304 crc32: u32,
11305 size: u32,
11306 local_offset: u32,
11307 }
11308
11309 fn push_u16(out: &mut Vec<u8>, value: u16) {
11310 out.extend_from_slice(&value.to_le_bytes());
11311 }
11312
11313 fn push_u32(out: &mut Vec<u8>, value: u32) {
11314 out.extend_from_slice(&value.to_le_bytes());
11315 }
11316
11317 if files.is_empty() {
11318 return Err(LinkError::InvalidPack {
11319 message: "cannot create an empty snapshot pack".to_string(),
11320 });
11321 }
11322 if files.len() > u16::MAX as usize {
11323 return Err(LinkError::PushTooLarge {
11324 detail: format!(
11325 "{} files (canonical ZIP32 packs cap at {})",
11326 files.len(),
11327 u16::MAX
11328 ),
11329 });
11330 }
11331
11332 let mut sorted: Vec<_> = files.iter().collect();
11333 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
11334 let mut previous: Option<&str> = None;
11335 for (path, content) in &sorted {
11336 if !safe_store_rel_path(path) {
11337 return Err(LinkError::UnsafePath {
11338 path: (*path).clone(),
11339 });
11340 }
11341 if previous == Some(path.as_str()) {
11342 return Err(LinkError::InvalidPack {
11343 message: format!("duplicate path `{path}`"),
11344 });
11345 }
11346 previous = Some(path.as_str());
11347 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
11348 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11349 })?;
11350 }
11351
11352 let mut out = Vec::new();
11353 let mut central = Vec::with_capacity(sorted.len());
11354 for (path, content) in sorted {
11355 let name = path.as_bytes();
11356 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
11357 message: format!("ZIP entry name is too long: `{path}`"),
11358 })?;
11359 let bytes = content.as_bytes();
11360 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
11361 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11362 })?;
11363 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11364 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11365 })?;
11366 let crc32 = crc32fast::hash(bytes);
11367
11368 push_u32(&mut out, LOCAL_HEADER);
11371 push_u16(&mut out, VERSION_20);
11372 push_u16(&mut out, UTF8_FLAG);
11373 push_u16(&mut out, STORED);
11374 push_u16(&mut out, DOS_TIME_MIDNIGHT);
11375 push_u16(&mut out, DOS_DATE_1980_01_01);
11376 push_u32(&mut out, crc32);
11377 push_u32(&mut out, size);
11378 push_u32(&mut out, size);
11379 push_u16(&mut out, name_len);
11380 push_u16(&mut out, 0); out.extend_from_slice(name);
11382 out.extend_from_slice(bytes);
11383
11384 central.push(CentralEntry {
11385 name,
11386 crc32,
11387 size,
11388 local_offset,
11389 });
11390 }
11391
11392 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11393 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11394 })?;
11395 for entry in ¢ral {
11396 push_u32(&mut out, CENTRAL_HEADER);
11397 push_u16(&mut out, MADE_BY_UNIX_20);
11398 push_u16(&mut out, VERSION_20);
11399 push_u16(&mut out, UTF8_FLAG);
11400 push_u16(&mut out, STORED);
11401 push_u16(&mut out, DOS_TIME_MIDNIGHT);
11402 push_u16(&mut out, DOS_DATE_1980_01_01);
11403 push_u32(&mut out, entry.crc32);
11404 push_u32(&mut out, entry.size);
11405 push_u32(&mut out, entry.size);
11406 push_u16(&mut out, entry.name.len() as u16);
11407 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);
11412 push_u32(&mut out, entry.local_offset);
11413 out.extend_from_slice(entry.name);
11414 }
11415 let central_size = u32::try_from(out.len())
11416 .ok()
11417 .and_then(|end| end.checked_sub(central_offset))
11418 .ok_or_else(|| LinkError::PushTooLarge {
11419 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
11420 })?;
11421 let entry_count = central.len() as u16;
11422
11423 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
11424 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
11427 push_u16(&mut out, entry_count);
11428 push_u32(&mut out, central_size);
11429 push_u32(&mut out, central_offset);
11430 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
11433 return Err(LinkError::PushTooLarge {
11434 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
11435 });
11436 }
11437 Ok(out)
11438}
11439
11440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11446pub enum Capability {
11447 Read,
11449 Write,
11451}
11452
11453impl Capability {
11454 pub fn as_str(self) -> &'static str {
11456 match self {
11457 Capability::Read => "read",
11458 Capability::Write => "write",
11459 }
11460 }
11461}
11462
11463pub fn grant_issue(
11469 cfg: &HubConfig,
11470 brain: &str,
11471 grantee: &str,
11472 can: Capability,
11473 scope: Option<&str>,
11474 until: Option<&str>,
11475) -> LinkResult<Value> {
11476 require_safe_ref(brain)?;
11477 let is_key_grantee = URL_SAFE_NO_PAD
11482 .decode(grantee)
11483 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
11484 .unwrap_or(false);
11485 if let Some(head) = v2_verified_head(cfg, brain)? {
11486 if is_key_grantee {
11487 let scope = scope.unwrap_or("");
11488 let preset = match can {
11489 Capability::Read => "viewer",
11490 Capability::Write => "editor",
11491 };
11492 let entropy = format!(
11493 "{}\0{}\0{}\0{}\0{}\0{}\0{}",
11494 normalized_origin(&cfg.hub)?,
11495 head.brain_id,
11496 head.control_revision,
11497 grantee,
11498 preset,
11499 scope,
11500 until.unwrap_or("")
11501 );
11502 let mut body = json!({
11503 "context": "external",
11504 "expected_control_revision": head.control_revision,
11505 "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
11506 "preset": preset,
11507 "principal_kind": "key",
11508 "public_key": grantee,
11509 "scope": scope,
11510 "scope_kind": "prefix",
11511 });
11512 if let Some(value) = until {
11513 body["expires_at"] = json!(value);
11514 }
11515 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11516 let response = ensure_ok(
11517 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11518 "v2 grant issue",
11519 )?;
11520 let expected_fingerprint = identity_fingerprint(grantee)?;
11521 if response.get("v").and_then(Value::as_u64) != Some(2)
11522 || response
11523 .get("id")
11524 .and_then(Value::as_str)
11525 .is_none_or(|id| !crate::ulid::is_ulid(id))
11526 || response.get("principal_kind").and_then(Value::as_str) != Some("key")
11527 || response.get("principal_id").and_then(Value::as_str)
11528 != Some(expected_fingerprint.as_str())
11529 || response
11530 .get("control_revision")
11531 .and_then(Value::as_str)
11532 .is_none_or(|value| !is_sha256(value))
11533 {
11534 return Err(invalid_feed(
11535 "v2 grant issue response is not authority-bound",
11536 ));
11537 }
11538 return Ok(response);
11539 }
11540 let mut body = json!({ "email": grantee, "capability": can.as_str() });
11546 if let Some(value) = scope {
11547 body["scopePrefix"] = json!(value);
11548 }
11549 if let Some(value) = until {
11550 body["expiresAt"] = json!(value);
11551 }
11552 let path = format!("/api/hub/brains/{}/grants", head.brain_id);
11553 return ensure_ok(
11554 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11555 "account grant issue",
11556 );
11557 }
11558 let _ = verified_remote_head(cfg, brain, false)?;
11559 let mut body = if is_key_grantee {
11560 json!({ "keySpki": grantee, "capability": can.as_str() })
11561 } else {
11562 json!({ "email": grantee, "capability": can.as_str() })
11563 };
11564 if let Some(s) = scope {
11565 body["scopePrefix"] = json!(s);
11566 }
11567 if let Some(u) = until {
11568 body["expiresAt"] = json!(u);
11569 }
11570 let path = format!("/api/hub/brains/{brain}/grants");
11571 ensure_ok(
11572 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11573 "grant issue",
11574 )
11575}
11576
11577pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
11579 require_safe_ref(brain)?;
11580 if let Some(head) = v2_verified_head(cfg, brain)? {
11581 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11582 let response = ensure_ok(
11583 request(cfg, "GET", &path, None, Auth::Required)?,
11584 "v2 grant list",
11585 )?;
11586 if response.get("v").and_then(Value::as_u64) != Some(2)
11587 || response.get("control_revision").and_then(Value::as_str)
11588 != Some(head.control_revision.as_str())
11589 || !response.get("grants").is_some_and(Value::is_array)
11590 {
11591 return Err(invalid_feed(
11592 "v2 grant list is not bound to the verified authority",
11593 ));
11594 }
11595 return Ok(response);
11596 }
11597 let _ = verified_remote_head(cfg, brain, false)?;
11598 let path = format!("/api/hub/brains/{brain}/grants");
11599 ensure_ok(
11600 request(cfg, "GET", &path, None, Auth::Required)?,
11601 "grant list",
11602 )
11603}
11604
11605pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
11608 require_safe_ref(brain)?;
11609 require_safe_grant_id(grant_id)?;
11610 if let Some(head) = v2_verified_head(cfg, brain)? {
11611 let entropy = format!(
11612 "{}\0{}\0{}\0{}",
11613 normalized_origin(&cfg.hub)?,
11614 head.brain_id,
11615 head.control_revision,
11616 grant_id
11617 );
11618 let body = json!({
11619 "expected_control_revision": head.control_revision,
11620 "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
11621 });
11622 let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
11623 let response = ensure_ok(
11624 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11625 "v2 grant revoke",
11626 )?;
11627 if response.get("v").and_then(Value::as_u64) != Some(2)
11628 || response.get("id").and_then(Value::as_str) != Some(grant_id)
11629 || response.get("revoked").and_then(Value::as_bool) != Some(true)
11630 || response
11631 .get("control_revision")
11632 .and_then(Value::as_str)
11633 .is_none_or(|value| !is_sha256(value))
11634 {
11635 return Err(invalid_feed(
11636 "v2 grant revocation response is not authority-bound",
11637 ));
11638 }
11639 return Ok(response);
11640 }
11641 let _ = verified_remote_head(cfg, brain, false)?;
11642 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
11643 ensure_ok(
11644 request(cfg, "DELETE", &path, None, Auth::Required)?,
11645 "grant revoke",
11646 )
11647}
11648
11649#[derive(Debug)]
11654struct VerifiedV2Proposal {
11655 value: Value,
11656 changes: Value,
11657 blobs: Vec<(String, u64, String)>,
11658}
11659
11660fn require_proposal_id(id: &str) -> LinkResult<()> {
11661 if crate::ulid::is_ulid(id) {
11662 Ok(())
11663 } else {
11664 Err(invalid_feed("proposal id is not a lowercase ULID"))
11665 }
11666}
11667
11668fn verified_v2_proposal(
11669 cfg: &HubConfig,
11670 head: &V2VerifiedHead,
11671 proposal_id: &str,
11672) -> LinkResult<VerifiedV2Proposal> {
11673 require_proposal_id(proposal_id)?;
11674 if head.view_kind != "full" {
11675 return Err(invalid_feed(
11676 "proposal review requires a full readable view",
11677 ));
11678 }
11679 let path = format!(
11680 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11681 head.brain_id
11682 );
11683 let value = ensure_ok(
11684 request_capped(
11685 cfg,
11686 "GET",
11687 &path,
11688 None,
11689 Auth::Required,
11690 MAX_FEED_RESPONSE_BYTES,
11691 )?,
11692 "v2 proposal",
11693 )?;
11694 verify_v2_proposal_value(head, proposal_id, value)
11695}
11696
11697fn verify_v2_proposal_value(
11698 head: &V2VerifiedHead,
11699 proposal_id: &str,
11700 value: Value,
11701) -> LinkResult<VerifiedV2Proposal> {
11702 if value.get("v").and_then(Value::as_u64) != Some(2) {
11703 return Err(invalid_feed("proposal response has an invalid version"));
11704 }
11705 let proposal = value
11706 .get("proposal")
11707 .and_then(Value::as_object)
11708 .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
11709 if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
11710 return Err(invalid_feed("proposal response changed its id"));
11711 }
11712 let payload_hash = proposal
11713 .get("payload_sha256")
11714 .and_then(Value::as_str)
11715 .filter(|hash| is_sha256(hash))
11716 .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
11717 let clear_hash = proposal
11718 .get("clear_sha256")
11719 .and_then(Value::as_str)
11720 .filter(|hash| is_sha256(hash))
11721 .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
11722 let submission_hash = proposal
11723 .get("submission_claim_sha256")
11724 .and_then(Value::as_str)
11725 .filter(|hash| is_sha256(hash))
11726 .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
11727 let submission = STANDARD
11728 .decode(
11729 proposal
11730 .get("submission_claim_base64")
11731 .and_then(Value::as_str)
11732 .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
11733 )
11734 .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
11735 let submission_value: Value = serde_json::from_slice(&submission)
11736 .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
11737 if crate::linkmd_v2::canonical_bytes(&submission_value)
11738 .map_err(|error| invalid_feed(error.to_string()))?
11739 != submission
11740 || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
11741 .map_err(|error| invalid_feed(error.to_string()))?
11742 != submission_hash
11743 {
11744 return Err(invalid_feed(
11745 "proposal submission claim is not canonical or addressed",
11746 ));
11747 }
11748 let envelope = submission_value
11749 .as_object()
11750 .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
11751 let claim = envelope
11752 .get("claim")
11753 .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
11754 let claim_object = claim
11755 .as_object()
11756 .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
11757 let actor_root = claim_object
11758 .get("actor_root")
11759 .and_then(Value::as_object)
11760 .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
11761 let public_key = envelope
11762 .get("public_key")
11763 .and_then(Value::as_str)
11764 .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
11765 let fingerprint = envelope
11766 .get("fingerprint")
11767 .and_then(Value::as_str)
11768 .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
11769 let signature = envelope
11770 .get("sig")
11771 .and_then(Value::as_str)
11772 .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
11773 let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
11774 .map_err(|error| invalid_feed(error.to_string()))?;
11775 let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
11776 let signer = format!("{fingerprint}:{public_key}");
11777 let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
11778 let grants = actor_root.get("grants").and_then(Value::as_array);
11779 let grants_are_canonical = grants.is_some_and(|items| {
11780 let mut prior: Option<&str> = None;
11781 items.iter().all(|item| {
11782 let Some(grant) = item.as_str() else {
11783 return false;
11784 };
11785 if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
11786 return false;
11787 }
11788 prior = Some(grant);
11789 true
11790 })
11791 });
11792 let optional_actor_field = |name: &str| {
11793 actor_root.get(name).is_some_and(|value| {
11794 value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
11795 })
11796 };
11797 let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
11798 if claim_object.get("v").and_then(Value::as_u64) != Some(2)
11799 || format!("{:x}", Sha256::digest(&der)) != fingerprint
11800 || head
11801 .trust
11802 .hub_signer
11803 .as_ref()
11804 .is_some_and(|known| known != &signer)
11805 || !matches!(
11806 actor_class,
11807 Some(
11808 "user"
11809 | "owned_agent"
11810 | "foreign_key"
11811 | "curation"
11812 | "inbox"
11813 | "restore"
11814 | "migration"
11815 | "operator_recovery"
11816 )
11817 )
11818 || actor_root
11819 .get("principal")
11820 .and_then(Value::as_str)
11821 .is_none_or(|value| value.is_empty())
11822 || actor_root
11823 .get("credential")
11824 .and_then(Value::as_str)
11825 .is_none_or(|value| value.is_empty())
11826 || !optional_actor_field("organization")
11827 || !optional_actor_field("role")
11828 || !grants_are_canonical
11829 || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
11830 || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11831 || !claim_object
11832 .get("mutation_id")
11833 .and_then(Value::as_str)
11834 .is_some_and(|value| {
11835 !value.is_empty()
11836 && value.len() <= 128
11837 && value.chars().enumerate().all(|(index, char)| {
11838 char.is_ascii_alphanumeric()
11839 || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
11840 })
11841 })
11842 || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
11843 || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
11844 || !claim_object
11845 .get("control_revision")
11846 .and_then(Value::as_str)
11847 .is_some_and(is_sha256)
11848 || submitted_at.is_none_or(|value| {
11849 chrono::DateTime::parse_from_rfc3339(value).is_err()
11850 || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
11851 })
11852 || !proposal
11853 .get("state")
11854 .and_then(Value::as_str)
11855 .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
11856 || proposal
11857 .get("expires_at")
11858 .and_then(Value::as_str)
11859 .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
11860 || proposal
11861 .get("proposer")
11862 .and_then(Value::as_object)
11863 .and_then(|value| value.get("class"))
11864 .and_then(Value::as_str)
11865 != actor_class
11866 {
11867 return Err(invalid_feed(
11868 "proposal submission claim does not bind the verified proposal",
11869 ));
11870 }
11871 let changes_b64 = proposal
11872 .get("changes_base64")
11873 .and_then(Value::as_str)
11874 .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
11875 let changes_bytes = STANDARD
11876 .decode(changes_b64)
11877 .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
11878 let changes: Value = serde_json::from_slice(&changes_bytes)
11879 .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
11880 if crate::linkmd_v2::canonical_bytes(&changes)
11881 .map_err(|error| invalid_feed(error.to_string()))?
11882 != changes_bytes
11883 || changes.get("v").and_then(Value::as_u64) != Some(2)
11884 || !changes.get("operations").is_some_and(Value::is_array)
11885 {
11886 return Err(invalid_feed("proposal changeset is not canonical v2"));
11887 }
11888 let blob_values = proposal
11889 .get("blobs")
11890 .and_then(Value::as_array)
11891 .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
11892 let mut blobs = Vec::with_capacity(blob_values.len());
11893 let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
11894 let mut prior_hash: Option<String> = None;
11895 for item in blob_values {
11896 let hash = item
11897 .get("sha256")
11898 .and_then(Value::as_str)
11899 .filter(|hash| is_sha256(hash))
11900 .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
11901 let bytes = item
11902 .get("bytes")
11903 .and_then(Value::as_u64)
11904 .filter(|bytes| *bytes <= MAX_STORE_BYTES)
11905 .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
11906 if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
11907 return Err(invalid_feed(
11908 "proposal blob declarations are not unique and sorted",
11909 ));
11910 }
11911 prior_hash = Some(hash.to_string());
11912 let endpoint = item
11913 .get("endpoint")
11914 .and_then(Value::as_str)
11915 .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
11916 let expected_endpoint = format!(
11917 "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
11918 head.brain_id
11919 );
11920 if endpoint != expected_endpoint {
11921 return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
11922 }
11923 descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
11924 blobs.push((hash.to_string(), bytes, endpoint.to_string()));
11925 }
11926 let descriptor = json!({
11927 "base": proposal.get("base").cloned().unwrap_or(Value::Null),
11928 "blobs": descriptor_blobs,
11929 "changes_base64": changes_b64,
11930 "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
11931 "v": 2,
11932 });
11933 let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
11934 .map_err(|error| invalid_feed(error.to_string()))?;
11935 if content_sha256(&descriptor_bytes) != clear_hash {
11936 return Err(invalid_feed(
11937 "proposal clear payload differs from its signed submission claim",
11938 ));
11939 }
11940 Ok(VerifiedV2Proposal {
11941 value,
11942 changes,
11943 blobs,
11944 })
11945}
11946
11947pub fn proposal_list(
11948 cfg: &HubConfig,
11949 brain: &str,
11950 state: &str,
11951 after: Option<&str>,
11952 limit: usize,
11953) -> LinkResult<Value> {
11954 require_safe_ref(brain)?;
11955 if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11956 return Err(invalid_feed("proposal state is invalid"));
11957 }
11958 if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11959 return Err(invalid_feed("proposal cursor is invalid"));
11960 }
11961 let head = v2_verified_head(cfg, brain)?
11962 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11963 let path = format!(
11964 "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11965 head.brain_id,
11966 limit.clamp(1, 100),
11967 after.map_or_else(String::new, |value| format!("&after={value}"))
11968 );
11969 ensure_ok(
11970 request_capped(
11971 cfg,
11972 "GET",
11973 &path,
11974 None,
11975 Auth::Required,
11976 MAX_FEED_RESPONSE_BYTES,
11977 )?,
11978 "v2 proposal list",
11979 )
11980}
11981
11982pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11983 require_safe_ref(brain)?;
11984 let head = v2_verified_head(cfg, brain)?
11985 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11986 Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11987}
11988
11989pub fn proposal_reject(
11990 cfg: &HubConfig,
11991 brain: &str,
11992 proposal_id: &str,
11993 mutation_id: &str,
11994 reason: &str,
11995) -> LinkResult<Value> {
11996 require_safe_ref(brain)?;
11997 require_proposal_id(proposal_id)?;
11998 let head = v2_verified_head(cfg, brain)?
11999 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12000 let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
12001 let body = json!({
12002 "mutation_id": mutation_id,
12003 "control_revision": head.control_revision,
12004 "reason": reason,
12005 });
12006 let path = format!(
12007 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
12008 head.brain_id
12009 );
12010 ensure_ok(
12011 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
12012 "v2 proposal rejection",
12013 )
12014}
12015
12016pub fn proposal_accept_exact(
12017 cfg: &HubConfig,
12018 brain: &str,
12019 proposal_id: &str,
12020 mutation_id: &str,
12021 reason: &str,
12022) -> LinkResult<Value> {
12023 require_safe_ref(brain)?;
12024 require_proposal_id(proposal_id)?;
12025 let head = v2_verified_head(cfg, brain)?
12026 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12027 let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
12028 let operations = proposal
12029 .changes
12030 .get("operations")
12031 .and_then(Value::as_array)
12032 .cloned()
12033 .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
12034 if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
12035 return Err(invalid_feed("proposal operation count is invalid"));
12036 }
12037 let mut downloaded = std::collections::BTreeMap::new();
12038 for (hash, bytes, endpoint) in &proposal.blobs {
12039 let body = ensure_raw_ok(
12040 request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
12041 "v2 proposal blob",
12042 )?;
12043 if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
12044 return Err(invalid_feed("proposal blob does not match its declaration"));
12045 }
12046 downloaded.insert(hash.clone(), body);
12047 }
12048 let remote = files_for_v2_view(
12049 &head,
12050 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
12051 );
12052 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
12053 let mut expected_candidate = remote.clone();
12054 let mut expected_candidate_assets = remote_assets;
12055 for operation in &operations {
12056 let op = operation
12057 .get("op")
12058 .and_then(Value::as_str)
12059 .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
12060 match op {
12061 "put" | "restore" => {
12062 let path = operation
12063 .get("path")
12064 .and_then(Value::as_str)
12065 .ok_or_else(|| invalid_feed("proposal write has no path"))?;
12066 crate::linkmd_v2::normalize_path(path)
12067 .map_err(|error| invalid_feed(error.to_string()))?;
12068 let hash = operation
12069 .get("blob")
12070 .and_then(Value::as_str)
12071 .filter(|hash| is_sha256(hash))
12072 .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
12073 let bytes = operation
12074 .get("bytes")
12075 .and_then(Value::as_u64)
12076 .ok_or_else(|| invalid_feed("proposal write has no size"))?;
12077 expected_candidate.insert(
12078 path.to_string(),
12079 V2BaselineFile {
12080 sha256: hash.to_string(),
12081 bytes,
12082 proof: None,
12083 },
12084 );
12085 }
12086 "delete" | "withdraw_from_hosting" => {
12087 let path = operation
12088 .get("path")
12089 .and_then(Value::as_str)
12090 .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
12091 crate::linkmd_v2::normalize_path(path)
12092 .map_err(|error| invalid_feed(error.to_string()))?;
12093 expected_candidate.remove(path);
12094 }
12095 "rename" => {
12096 let from = operation
12097 .get("from")
12098 .and_then(Value::as_str)
12099 .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
12100 let to = operation
12101 .get("to")
12102 .and_then(Value::as_str)
12103 .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
12104 crate::linkmd_v2::normalize_path(from)
12105 .and_then(|_| crate::linkmd_v2::normalize_path(to))
12106 .map_err(|error| invalid_feed(error.to_string()))?;
12107 let hash = operation
12108 .get("blob")
12109 .and_then(Value::as_str)
12110 .filter(|hash| is_sha256(hash))
12111 .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
12112 let bytes = operation
12113 .get("bytes")
12114 .and_then(Value::as_u64)
12115 .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
12116 expected_candidate.remove(from);
12117 expected_candidate.insert(
12118 to.to_string(),
12119 V2BaselineFile {
12120 sha256: hash.to_string(),
12121 bytes,
12122 proof: None,
12123 },
12124 );
12125 }
12126 "asset_delete" => {
12127 let path = operation
12128 .get("path")
12129 .and_then(Value::as_str)
12130 .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
12131 expected_candidate_assets.remove(path);
12132 }
12133 "asset_withdraw" => {
12134 let path = operation
12135 .get("path")
12136 .and_then(Value::as_str)
12137 .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
12138 if !expected_candidate_assets.contains_key(path) {
12139 return Err(invalid_feed("proposal withdraws an unknown asset"));
12140 }
12141 let Some(asset) = operation.get("asset").and_then(Value::as_object) else {
12142 let prior = expected_candidate_assets
12146 .get_mut(path)
12147 .expect("presence checked above");
12148 prior.disposition = "withheld".to_string();
12149 prior.leaf_hash.clear();
12150 continue;
12151 };
12152 let blob_sha256 = asset
12153 .get("blob_sha256")
12154 .and_then(Value::as_str)
12155 .filter(|hash| is_sha256(hash))
12156 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12157 let bytes = asset
12158 .get("bytes")
12159 .and_then(Value::as_u64)
12160 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12161 let media_type = asset
12162 .get("media_type")
12163 .and_then(Value::as_str)
12164 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12165 let wrappers = asset
12166 .get("wrappers")
12167 .and_then(Value::as_array)
12168 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12169 .iter()
12170 .map(|wrapper| {
12171 wrapper
12172 .as_str()
12173 .map(str::to_string)
12174 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12175 })
12176 .collect::<LinkResult<Vec<_>>>()?;
12177 let required = asset
12178 .get("required")
12179 .and_then(Value::as_bool)
12180 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12181 if asset.get("disposition").and_then(Value::as_str) != Some("withheld") {
12182 return Err(invalid_feed("proposal asset withdrawal is not withheld"));
12183 }
12184 expected_candidate_assets.insert(
12185 path.to_string(),
12186 V2BaselineAsset {
12187 blob_sha256: blob_sha256.to_string(),
12188 bytes,
12189 media_type: media_type.to_string(),
12190 wrappers,
12191 required,
12192 disposition: "withheld".to_string(),
12193 leaf_hash: String::new(),
12194 },
12195 );
12196 }
12197 "asset_put" | "asset_resume" => {
12198 let path = operation
12199 .get("path")
12200 .and_then(Value::as_str)
12201 .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
12202 let asset = operation
12203 .get("asset")
12204 .and_then(Value::as_object)
12205 .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
12206 let blob_sha256 = asset
12207 .get("blob_sha256")
12208 .and_then(Value::as_str)
12209 .filter(|hash| is_sha256(hash))
12210 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12211 let bytes = asset
12212 .get("bytes")
12213 .and_then(Value::as_u64)
12214 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12215 let media_type = asset
12216 .get("media_type")
12217 .and_then(Value::as_str)
12218 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12219 let wrappers = asset
12220 .get("wrappers")
12221 .and_then(Value::as_array)
12222 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12223 .iter()
12224 .map(|wrapper| {
12225 wrapper
12226 .as_str()
12227 .map(str::to_string)
12228 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12229 })
12230 .collect::<LinkResult<Vec<_>>>()?;
12231 let required = asset
12232 .get("required")
12233 .and_then(Value::as_bool)
12234 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12235 let disposition = asset
12236 .get("disposition")
12237 .and_then(Value::as_str)
12238 .filter(|value| matches!(*value, "hosted" | "withheld"))
12239 .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
12240 expected_candidate_assets.insert(
12241 path.to_string(),
12242 V2BaselineAsset {
12243 blob_sha256: blob_sha256.to_string(),
12244 bytes,
12245 media_type: media_type.to_string(),
12246 wrappers,
12247 required,
12248 disposition: disposition.to_string(),
12249 leaf_hash: String::new(),
12250 },
12251 );
12252 }
12253 _ => return Err(invalid_feed("proposal operation kind is unsupported")),
12254 }
12255 }
12256 let base = head.pointer.as_ref().map(|pointer| {
12257 json!({
12258 "seq": pointer.seq,
12259 "commit_hash": pointer.commit_hash,
12260 "content_root": pointer.content_root,
12261 "asset_root": pointer.asset_root,
12262 })
12263 });
12264 let mut body = json!({
12265 "mutation_id": mutation_id,
12266 "base": base,
12267 "rebase": "strict",
12268 "reason": reason,
12269 "operations": operations,
12270 "blobs": downloaded
12271 .iter()
12272 .map(|(sha256, bytes)| json!({
12273 "sha256": sha256,
12274 "bytes": bytes.len(),
12275 "content_base64": STANDARD.encode(bytes),
12276 }))
12277 .collect::<Vec<_>>(),
12278 "proposal_id": proposal_id,
12279 "proposal_mode": "exact",
12280 });
12281 let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
12282 total
12283 .checked_add(bytes.len())
12284 .ok_or_else(|| LinkError::PushTooLarge {
12285 detail: "proposal changed-byte total overflow".to_string(),
12286 })
12287 })?;
12288 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
12289 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12290 for operation in &operations {
12291 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
12292 return Err(invalid_feed("proposal upload operation has no kind"));
12293 };
12294 let hash = match kind {
12295 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
12296 "asset_put" | "asset_resume" => operation
12297 .get("asset")
12298 .and_then(|asset| asset.get("blob_sha256"))
12299 .and_then(Value::as_str),
12300 _ => None,
12301 };
12302 let Some(hash) = hash else { continue };
12303 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
12304 if kind == "rename" {
12305 for field in ["from", "to"] {
12306 coordinates.insert(
12307 operation
12308 .get(field)
12309 .and_then(Value::as_str)
12310 .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
12311 .to_string(),
12312 );
12313 }
12314 } else {
12315 let path = operation
12316 .get("path")
12317 .and_then(Value::as_str)
12318 .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
12319 coordinates.insert(if kind.starts_with("asset_") {
12320 format!("assets/{path}")
12321 } else {
12322 path.to_string()
12323 });
12324 }
12325 }
12326 let declarations = downloaded
12327 .iter()
12328 .map(|(sha256, bytes)| {
12329 json!({
12330 "sha256": sha256,
12331 "bytes": bytes.len(),
12332 "coordinates": coordinates_by_hash
12333 .get(sha256)
12334 .into_iter()
12335 .flatten()
12336 .collect::<Vec<_>>(),
12337 })
12338 })
12339 .collect::<Vec<_>>();
12340 let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
12341 for batch in batch_upload_declarations(declarations) {
12342 let reserved = reserve_upload_window(
12343 cfg,
12344 &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
12345 &json!({ "blobs": batch }),
12346 "prepare proposal blob transport",
12347 )?;
12348 let reserved_items = reserved
12349 .get("uploads")
12350 .and_then(Value::as_array)
12351 .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
12352 items.extend(reserved_items.iter().cloned());
12353 }
12354 if items.len() != downloaded.len() {
12355 return Err(invalid_feed("proposal upload reservation changed the set"));
12356 }
12357 let mut references = Vec::with_capacity(items.len());
12358 for item in items {
12359 let hash = item
12360 .get("sha256")
12361 .and_then(Value::as_str)
12362 .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
12363 let bytes = downloaded
12364 .get(hash)
12365 .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
12366 let reservation_id = item
12367 .get("reservation_id")
12368 .and_then(Value::as_str)
12369 .filter(|id| crate::ulid::is_ulid(id))
12370 .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
12371 let expected_coordinates = coordinates_by_hash
12372 .get(hash)
12373 .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
12374 let returned_coordinates = item
12375 .get("coordinates")
12376 .and_then(Value::as_array)
12377 .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
12378 if returned_coordinates.len() != expected_coordinates.len()
12379 || returned_coordinates
12380 .iter()
12381 .zip(expected_coordinates)
12382 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
12383 {
12384 return Err(invalid_feed(
12385 "proposal upload reservation changed its coordinates",
12386 ));
12387 }
12388 match item.get("status").and_then(Value::as_str) {
12389 Some("upload") => put_presigned(
12390 cfg,
12391 item.get("url")
12392 .and_then(Value::as_str)
12393 .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
12394 item.get("headers").unwrap_or(&Value::Null),
12395 bytes,
12396 )?,
12397 Some("already_present") => {}
12398 _ => return Err(invalid_feed("proposal upload status is invalid")),
12399 }
12400 references.push(json!({
12401 "sha256": hash,
12402 "bytes": bytes.len(),
12403 "reservation_id": reservation_id,
12404 }));
12405 }
12406 body["blobs"] = Value::Array(references);
12407 }
12408 stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
12412 let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
12413 let mut result = ensure_ok(
12414 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
12415 "exact proposal acceptance",
12416 )?;
12417 let mut candidate_hub_signer = None;
12418 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
12419 let request_id = result
12420 .get("request_id")
12421 .and_then(Value::as_str)
12422 .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
12423 .to_string();
12424 let challenge = result
12425 .get("signing_challenge")
12426 .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
12427 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
12428 cfg,
12429 &head,
12430 &expected_candidate,
12431 &expected_candidate_assets,
12432 mutation_id,
12433 &v2_signed_request_view(&body, &operations),
12434 challenge,
12435 )?;
12436 body["signing_challenge_id"] = Value::String(challenge_id);
12437 body["signature_base64url"] = Value::String(signature);
12438 candidate_hub_signer = Some(actor_signer);
12439 result = ensure_ok(
12440 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
12441 "signed exact proposal acceptance",
12442 )?;
12443 }
12444 let refreshed = v2_verified_head(cfg, brain)?
12445 .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
12446 if candidate_hub_signer
12447 .as_ref()
12448 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
12449 || refreshed
12450 .pointer
12451 .as_ref()
12452 .map(|pointer| pointer.commit_hash.as_str())
12453 != result.get("commit_hash").and_then(Value::as_str)
12454 || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12455 || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
12456 {
12457 return Err(LinkError::RemoteAdvancedDuringSync);
12458 }
12459 accept_v2_head(cfg, &refreshed)?;
12460 Ok(result)
12461}
12462
12463pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
12474 require_valid_handle(handle)?;
12475 if body.len() as u64 > MAX_PROPOSE_BYTES {
12476 return Err(LinkError::ProposeTooLarge {
12477 bytes: body.len() as u64,
12478 });
12479 }
12480 let payload = json!({ "app": app, "body": body });
12481 let (path, auth) = if crate::ulid::is_ulid(handle) {
12486 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
12487 } else {
12488 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
12489 };
12490 ensure_ok(
12491 request(cfg, "POST", &path, Some(&payload), auth)?,
12492 "propose",
12493 )
12494}
12495
12496#[derive(Debug, serde::Serialize)]
12502pub struct Head {
12503 pub brain: String,
12505 pub seq: u64,
12507 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
12509 pub updated_at: Option<String>,
12510 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
12512 pub feed_hash: Option<String>,
12513 pub verified: bool,
12516}
12517
12518struct BoundedVecVisitor<T, const MAX: usize> {
12519 label: &'static str,
12520 marker: std::marker::PhantomData<T>,
12521}
12522
12523impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
12524where
12525 T: Deserialize<'de>,
12526{
12527 type Value = Vec<T>;
12528
12529 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12530 write!(formatter, "at most {MAX} {}", self.label)
12531 }
12532
12533 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
12534 where
12535 A: serde::de::SeqAccess<'de>,
12536 {
12537 if sequence.size_hint().is_some_and(|size| size > MAX) {
12538 return Err(serde::de::Error::custom(format!(
12539 "{} exceeds the {MAX}-item limit",
12540 self.label
12541 )));
12542 }
12543 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
12544 while let Some(value) = sequence.next_element()? {
12545 if values.len() == MAX {
12546 return Err(serde::de::Error::custom(format!(
12547 "{} exceeds the {MAX}-item limit",
12548 self.label
12549 )));
12550 }
12551 values.push(value);
12552 }
12553 Ok(values)
12554 }
12555}
12556
12557fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
12558 deserializer: D,
12559 label: &'static str,
12560) -> Result<Vec<T>, D::Error>
12561where
12562 D: serde::Deserializer<'de>,
12563 T: Deserialize<'de>,
12564{
12565 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
12566 label,
12567 marker: std::marker::PhantomData,
12568 })
12569}
12570
12571fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
12572where
12573 D: serde::Deserializer<'de>,
12574{
12575 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
12576}
12577
12578fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12579where
12580 D: serde::Deserializer<'de>,
12581{
12582 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
12583}
12584
12585fn deserialize_previous_identities<'de, D>(
12586 deserializer: D,
12587) -> Result<Vec<PreviousIdentity>, D::Error>
12588where
12589 D: serde::Deserializer<'de>,
12590{
12591 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
12592 deserializer,
12593 "previous identities",
12594 )
12595}
12596
12597fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12598where
12599 D: serde::Deserializer<'de>,
12600{
12601 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
12602 deserializer,
12603 "rotation statements",
12604 )
12605}
12606
12607fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
12608where
12609 D: serde::Deserializer<'de>,
12610{
12611 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
12612}
12613
12614#[derive(Debug, Clone, Deserialize, Serialize)]
12615struct FeedFile {
12616 path: String,
12617 sha256: String,
12618 bytes: u64,
12619}
12620
12621#[cfg(test)]
12622#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12623enum V1DisclosureError {
12624 DuplicateFile,
12625 DuplicateRemoved,
12626 PushManifestMismatch,
12627 EditMissingChange,
12628 EditFalseFile,
12629 RemovedMismatch,
12630}
12631
12632#[cfg(test)]
12636fn verify_v1_manifest_disclosure(
12637 kind: &str,
12638 previous: &[FeedFile],
12639 resulting: &[FeedFile],
12640 files: &[FeedFile],
12641 removed: &[String],
12642) -> Result<(), V1DisclosureError> {
12643 fn as_map(
12644 files: &[FeedFile],
12645 ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
12646 let mut result = std::collections::BTreeMap::new();
12647 for file in files {
12648 if result
12649 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
12650 .is_some()
12651 {
12652 return Err(V1DisclosureError::DuplicateFile);
12653 }
12654 }
12655 Ok(result)
12656 }
12657 let previous = as_map(previous)?;
12658 let resulting = as_map(resulting)?;
12659 let disclosed = as_map(files)?;
12660 let removed_set: std::collections::BTreeSet<&str> =
12661 removed.iter().map(String::as_str).collect();
12662 if removed_set.len() != removed.len() {
12663 return Err(V1DisclosureError::DuplicateRemoved);
12664 }
12665 let expected_removed: std::collections::BTreeSet<&str> = previous
12666 .keys()
12667 .copied()
12668 .filter(|path| !resulting.contains_key(path))
12669 .collect();
12670 if removed_set != expected_removed {
12671 return Err(V1DisclosureError::RemovedMismatch);
12672 }
12673 if kind == "push" {
12674 return if disclosed == resulting {
12675 Ok(())
12676 } else {
12677 Err(V1DisclosureError::PushManifestMismatch)
12678 };
12679 }
12680 if kind != "edit" {
12681 return Err(V1DisclosureError::EditFalseFile);
12682 }
12683 if disclosed
12684 .iter()
12685 .any(|(path, value)| resulting.get(path) != Some(value))
12686 {
12687 return Err(V1DisclosureError::EditFalseFile);
12688 }
12689 for (path, value) in &resulting {
12690 if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
12691 return Err(V1DisclosureError::EditMissingChange);
12692 }
12693 }
12694 Ok(())
12695}
12696
12697#[derive(Debug, Clone, Deserialize, Serialize)]
12698struct FeedEntry {
12699 v: u8,
12700 seq: u64,
12701 ts: String,
12702 brain: String,
12703 public_key: String,
12704 kind: String,
12705 op: String,
12706 pack_sha256: String,
12707 #[serde(deserialize_with = "deserialize_feed_files")]
12708 files: Vec<FeedFile>,
12709 #[serde(deserialize_with = "deserialize_removed_paths")]
12710 removed: Vec<String>,
12711 prev_entry_hash: Option<String>,
12712 sig: String,
12713}
12714
12715#[derive(Serialize)]
12716struct UnsignedFeedEntry<'a> {
12717 v: u8,
12718 seq: u64,
12719 ts: &'a str,
12720 brain: &'a str,
12721 public_key: &'a str,
12722 kind: &'a str,
12723 op: &'a str,
12724 pack_sha256: &'a str,
12725 files: &'a [FeedFile],
12726 removed: &'a [String],
12727 prev_entry_hash: &'a Option<String>,
12728}
12729
12730#[derive(Debug, Clone, Deserialize, Serialize)]
12731struct FeedItem {
12732 hash: String,
12733 entry: FeedEntry,
12734}
12735
12736#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12737struct FeedIdentity {
12738 fingerprint: String,
12739 #[serde(rename = "publicKeySpki")]
12740 public_key_spki: String,
12741 #[serde(default, deserialize_with = "deserialize_previous_identities")]
12745 previous: Vec<PreviousIdentity>,
12746 #[serde(default, deserialize_with = "deserialize_rotations")]
12749 rotations: Vec<String>,
12750}
12751
12752#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12753struct PreviousIdentity {
12754 fingerprint: String,
12755 #[serde(rename = "publicKeySpki")]
12756 public_key_spki: String,
12757}
12758
12759#[derive(Debug, Deserialize)]
12760struct FeedResponse {
12761 #[serde(rename = "headSeq")]
12762 head_seq: u64,
12763 #[serde(rename = "feedHash")]
12764 feed_hash: Option<String>,
12765 identity: Option<FeedIdentity>,
12766 #[serde(deserialize_with = "deserialize_feed_items")]
12767 entries: Vec<FeedItem>,
12768 #[serde(rename = "scopeLimited")]
12769 scope_limited: bool,
12770}
12771
12772#[derive(Debug, Deserialize, Serialize)]
12773#[serde(deny_unknown_fields)]
12774struct RotationStatement {
12775 v: u8,
12776 op: String,
12777 brain: String,
12778 public_key: String,
12779 new_brain: String,
12780 new_public_key: String,
12781 prior_head_seq: u64,
12782 prior_feed_hash: Option<String>,
12783 ts: String,
12784 sig: String,
12785}
12786
12787#[derive(Debug, Clone, Deserialize, Serialize)]
12788struct TrustState {
12789 v: u8,
12790 origin: String,
12791 #[serde(default)]
12795 requested: String,
12796 brain: String,
12798 #[serde(default, skip_serializing_if = "Option::is_none")]
12801 home: Option<String>,
12802 anchor: String,
12803 current: String,
12804 #[serde(rename = "headSeq")]
12805 head_seq: u64,
12806 #[serde(rename = "feedHash")]
12807 feed_hash: Option<String>,
12808 #[serde(default)]
12812 rotations: Vec<String>,
12813 #[serde(default, skip_serializing_if = "Option::is_none")]
12816 hub_signer: Option<String>,
12817 #[serde(default, skip_serializing_if = "Option::is_none")]
12820 protocol_profile: Option<String>,
12821}
12822
12823fn accepted_as_v2(state: &TrustState) -> bool {
12824 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
12825}
12826
12827fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
12828 let directory = open_trust_dir(cfg)?;
12829 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
12830 return Ok(true);
12831 }
12832 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
12833 return Ok(false);
12834 };
12835 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
12836}
12837
12838#[derive(Debug, Clone, Deserialize, Serialize)]
12839struct AliasBinding {
12840 v: u8,
12841 origin: String,
12842 requested: String,
12843 brain: String,
12844 #[serde(default, skip_serializing_if = "Option::is_none")]
12845 home: Option<String>,
12846}
12847
12848struct VerifiedRemote {
12849 head: Head,
12850 identity: Option<FeedIdentity>,
12851 head_entry: Option<FeedItem>,
12852 entries: Vec<FeedItem>,
12854 anchor: Option<String>,
12855}
12856
12857fn invalid_feed(message: impl Into<String>) -> LinkError {
12858 LinkError::InvalidFeed {
12859 message: message.into(),
12860 }
12861}
12862
12863fn is_sha256(value: &str) -> bool {
12864 value.len() == 64
12865 && value
12866 .bytes()
12867 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
12868}
12869
12870fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
12871 let der = URL_SAFE_NO_PAD
12872 .decode(public_key_spki)
12873 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
12874 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
12875 return Err(invalid_feed(
12876 "identity public key is not a valid Ed25519 SPKI",
12877 ));
12878 }
12879 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
12880}
12881
12882fn verify_identity_chain(
12886 identity: &FeedIdentity,
12887 pinned: Option<&TrustState>,
12888) -> LinkResult<String> {
12889 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
12890 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
12891 {
12892 return Err(invalid_feed(
12893 "identity rotation history exceeds the client cap",
12894 ));
12895 }
12896 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
12897 return Err(invalid_feed(
12898 "current identity fingerprint does not match its public key",
12899 ));
12900 }
12901 for previous in &identity.previous {
12902 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
12903 return Err(invalid_feed(
12904 "previous identity fingerprint does not match its public key",
12905 ));
12906 }
12907 }
12908 if identity.rotations.len() != identity.previous.len() {
12909 return Err(invalid_feed(
12910 "identity history is missing an old-key-signed rotation statement",
12911 ));
12912 }
12913
12914 let mut chain: Vec<(&str, &str)> = identity
12918 .previous
12919 .iter()
12920 .rev()
12921 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
12922 .collect();
12923 chain.push((&identity.fingerprint, &identity.public_key_spki));
12924
12925 for (index, raw) in identity.rotations.iter().enumerate() {
12926 let statement: RotationStatement = serde_json::from_str(raw)
12927 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
12928 let (old_fingerprint, old_spki) = chain[index];
12929 let (new_fingerprint, new_spki) = chain[index + 1];
12930 if statement.v != 1
12931 || statement.op != "rotate"
12932 || statement.brain != format!("ed25519:{old_fingerprint}")
12933 || statement.public_key != old_spki
12934 || statement.new_brain != format!("ed25519:{new_fingerprint}")
12935 || statement.new_public_key != new_spki
12936 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
12937 || (statement.prior_head_seq > 0
12938 && statement
12939 .prior_feed_hash
12940 .as_deref()
12941 .is_none_or(|hash| !is_sha256(hash)))
12942 {
12943 return Err(invalid_feed(
12944 "rotation statement does not connect adjacent identities",
12945 ));
12946 }
12947 let unsigned = serde_json::to_string(&UnsignedRotation {
12948 v: statement.v,
12949 op: &statement.op,
12950 brain: &statement.brain,
12951 public_key: &statement.public_key,
12952 new_brain: &statement.new_brain,
12953 new_public_key: &statement.new_public_key,
12954 prior_head_seq: statement.prior_head_seq,
12955 prior_feed_hash: statement.prior_feed_hash.as_deref(),
12956 ts: statement.ts.clone(),
12957 })
12958 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
12959 let exact = format!(
12960 "{},\"sig\":\"{}\"}}",
12961 &unsigned[..unsigned.len() - 1],
12962 statement.sig
12963 );
12964 if exact != *raw {
12965 return Err(invalid_feed(
12966 "rotation statement is not in normative serialization",
12967 ));
12968 }
12969 let der = URL_SAFE_NO_PAD
12970 .decode(old_spki)
12971 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
12972 let signature = URL_SAFE_NO_PAD
12973 .decode(&statement.sig)
12974 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
12975 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
12976 .verify(unsigned.as_bytes(), &signature)
12977 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
12978 if index > 0 {
12979 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
12980 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
12981 if statement.prior_head_seq < prior.prior_head_seq {
12982 return Err(invalid_feed("rotation feed boundaries move backward"));
12983 }
12984 }
12985 }
12986
12987 let anchor = format!("ed25519:{}", chain[0].0);
12988 let current = format!("ed25519:{}", identity.fingerprint);
12989 if let Some(pin) = pinned {
12990 if pin.anchor != anchor {
12991 return Err(invalid_feed(
12992 "served identity chain does not descend from the pinned anchor",
12993 ));
12994 }
12995 if !chain
12996 .iter()
12997 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12998 {
12999 return Err(invalid_feed(
13000 "served identity chain forked away from the last pinned identity",
13001 ));
13002 }
13003 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
13004 return Err(invalid_feed("served identity discarded its rotation chain"));
13005 }
13006 if pin.v >= 2
13007 && (identity.rotations.len() < pin.rotations.len()
13008 || identity.rotations[..pin.rotations.len()] != pin.rotations)
13009 {
13010 return Err(invalid_feed(
13011 "served identity rewrote the locally accepted rotation history",
13012 ));
13013 }
13014 }
13015 Ok(anchor)
13016}
13017
13018fn verify_rotation_feed_boundaries(
13019 identity: &FeedIdentity,
13020 pinned: Option<&TrustState>,
13021 observed: &[FeedItem],
13022 advertised_seq: u64,
13023) -> LinkResult<()> {
13024 let mut chain: Vec<String> = identity
13025 .previous
13026 .iter()
13027 .rev()
13028 .map(|previous| format!("ed25519:{}", previous.fingerprint))
13029 .collect();
13030 chain.push(format!("ed25519:{}", identity.fingerprint));
13031 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
13032
13033 for (index, raw) in identity.rotations.iter().enumerate() {
13034 let rotation: RotationStatement = serde_json::from_str(raw)
13035 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13036 if rotation.prior_head_seq > advertised_seq {
13037 return Err(invalid_feed(
13038 "rotation claims a feed boundary beyond the advertised head",
13039 ));
13040 }
13041 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
13042 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
13043 return Err(invalid_feed(
13044 "newly disclosed rotation predates the local feed checkpoint",
13045 ));
13046 }
13047 }
13048 let actual = if rotation.prior_head_seq == 0 {
13049 None
13050 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
13051 pinned.and_then(|pin| pin.feed_hash.as_deref())
13052 } else {
13053 observed
13054 .iter()
13055 .find(|item| item.entry.seq == rotation.prior_head_seq)
13056 .map(|item| item.hash.as_str())
13057 };
13058 if let Some(actual) = actual {
13059 if rotation.prior_feed_hash.as_deref() != Some(actual) {
13060 return Err(invalid_feed(
13061 "rotation statement does not commit the verified feed boundary",
13062 ));
13063 }
13064 } else if rotation.prior_head_seq == 0 {
13065 } else if pinned.is_some_and(|pin| {
13068 pinned_index.is_some_and(|pin_index| index >= pin_index)
13069 || rotation.prior_head_seq >= pin.head_seq
13070 }) {
13071 return Err(invalid_feed(
13072 "rotation feed boundary was not present in the verified chain",
13073 ));
13074 }
13075 }
13076 Ok(())
13077}
13078
13079fn reject_retired_signer_after_checkpoint(
13084 identity: &FeedIdentity,
13085 pinned: Option<&TrustState>,
13086 item: &FeedItem,
13087) -> LinkResult<()> {
13088 let Some(pin) = pinned else {
13089 return Ok(());
13090 };
13091 if item.entry.seq <= pin.head_seq {
13092 return Ok(());
13093 }
13094 let mut chain: Vec<String> = identity
13095 .previous
13096 .iter()
13097 .rev()
13098 .map(|previous| format!("ed25519:{}", previous.fingerprint))
13099 .collect();
13100 chain.push(format!("ed25519:{}", identity.fingerprint));
13101 let pinned_index = chain
13102 .iter()
13103 .position(|key| key == &pin.current)
13104 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
13105 let signer_index = chain
13106 .iter()
13107 .position(|key| key == &item.entry.brain)
13108 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
13109 if signer_index < pinned_index {
13110 return Err(invalid_feed(
13111 "a retired identity attempted to sign after the local checkpoint",
13112 ));
13113 }
13114 Ok(())
13115}
13116
13117fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
13118 let origin = normalized_origin(&cfg.hub)?;
13119 let key = format!(
13120 "{:x}",
13121 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
13122 );
13123 Ok(format!("{key}.json"))
13124}
13125
13126fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
13127 let origin = normalized_origin(&cfg.hub)?;
13128 let key = format!(
13129 "{:x}",
13130 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
13131 );
13132 Ok(format!("alias-{key}.json"))
13133}
13134
13135#[cfg(any(unix, windows))]
13136struct TrustLock {
13137 _file: std::fs::File,
13138}
13139
13140#[cfg(unix)]
13141fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13142 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13143
13144 let lock_string = format!(".{state_name}.lock");
13145 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
13146 let fd = unsafe {
13147 libc::openat(
13148 directory.as_raw_fd(),
13149 lock_name.as_ptr(),
13150 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13151 0o600,
13152 )
13153 };
13154 if fd < 0 {
13155 return Err(std::io::Error::last_os_error().into());
13156 }
13157 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13158 if !file.metadata()?.is_file() {
13159 return Err(LinkError::UnsafePath { path: lock_string });
13160 }
13161 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
13162 return Err(std::io::Error::last_os_error().into());
13163 }
13164 Ok(TrustLock { _file: file })
13165}
13166
13167#[cfg(windows)]
13168fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13169 let lock_name = format!(".{state_name}.lock");
13170 let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
13171 Ok(TrustLock { _file: file })
13172}
13173
13174#[cfg(any(unix, windows))]
13175fn lock_trust_many(
13176 cfg: &HubConfig,
13177 directory: &std::fs::File,
13178 refs: &[&str],
13179) -> LinkResult<Vec<TrustLock>> {
13180 let mut names = refs
13181 .iter()
13182 .map(|reference| trust_file_name(cfg, reference))
13183 .collect::<LinkResult<Vec<_>>>()?;
13184 names.sort();
13185 names.dedup();
13186 names
13187 .iter()
13188 .map(|name| lock_trust_name(directory, name))
13189 .collect()
13190}
13191
13192#[cfg(not(any(unix, windows)))]
13193fn lock_trust_many(
13194 _cfg: &HubConfig,
13195 _directory: &TrustDirectory,
13196 _refs: &[&str],
13197) -> LinkResult<Vec<()>> {
13198 Err(LinkError::UnsupportedPlatform {
13199 operation: "verified link.md state",
13200 })
13201}
13202
13203#[cfg(any(unix, windows))]
13204type TrustDirectory = std::fs::File;
13205
13206#[cfg(not(any(unix, windows)))]
13207struct TrustDirectory;
13208
13209#[cfg(unix)]
13210fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13211 use std::os::fd::AsRawFd as _;
13212
13213 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
13214 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
13215 return Err(std::io::Error::last_os_error().into());
13216 }
13217 directory.sync_all()?;
13218 Ok(directory)
13219}
13220
13221#[cfg(windows)]
13222fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13223 let marker = cfg.state_dir.join("trust").join(".directory");
13224 crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
13225 Ok(crate::fsx::open_directory_nofollow(
13226 marker.parent().expect("trust marker has a parent"),
13227 )?)
13228}
13229
13230#[cfg(not(any(unix, windows)))]
13231fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13232 Err(LinkError::UnsupportedPlatform {
13233 operation: "verified link.md state",
13234 })
13235}
13236
13237#[cfg(unix)]
13238fn load_trust_in(
13239 cfg: &HubConfig,
13240 directory: &TrustDirectory,
13241 requested: &str,
13242) -> LinkResult<Option<TrustState>> {
13243 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13244
13245 let name_string = trust_file_name(cfg, requested)?;
13246 let name = c_name(name_string.as_bytes(), &name_string)?;
13247 let fd = unsafe {
13248 libc::openat(
13249 directory.as_raw_fd(),
13250 name.as_ptr(),
13251 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13252 )
13253 };
13254 if fd < 0 {
13255 let error = std::io::Error::last_os_error();
13256 if error.kind() == std::io::ErrorKind::NotFound {
13257 return Ok(None);
13258 }
13259 return Err(LinkError::UnsafePath { path: name_string });
13260 }
13261 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13262 if !file.metadata()?.is_file() {
13263 return Err(LinkError::UnsafePath { path: name_string });
13264 }
13265 let mut bytes = Vec::new();
13266 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
13267 if bytes.len() > 1024 * 1024 {
13268 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
13269 }
13270 let mut state: TrustState = serde_json::from_slice(&bytes)
13271 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13272 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13273 return Err(invalid_feed(
13274 "local identity/feed checkpoint does not match this hub and brain",
13275 ));
13276 }
13277 if state.v == 1 {
13278 if state.brain != requested {
13282 return Err(invalid_feed(
13283 "legacy checkpoint is not bound to the requested brain id",
13284 ));
13285 }
13286 state.requested = requested.to_string();
13287 } else if state.requested != requested {
13288 return Err(invalid_feed(
13289 "local identity/feed checkpoint is bound to a different requested ref",
13290 ));
13291 }
13292 Ok(Some(state))
13293}
13294
13295#[cfg(windows)]
13296fn load_trust_in(
13297 cfg: &HubConfig,
13298 directory: &TrustDirectory,
13299 requested: &str,
13300) -> LinkResult<Option<TrustState>> {
13301 let name = trust_file_name(cfg, requested)?;
13302 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13303 let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
13304 Ok(bytes) => bytes,
13305 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13306 Err(_) => return Err(LinkError::UnsafePath { path: name }),
13307 };
13308 let mut state: TrustState = serde_json::from_slice(&bytes)
13309 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13310 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13311 return Err(invalid_feed(
13312 "local identity/feed checkpoint does not match this hub and brain",
13313 ));
13314 }
13315 if state.v == 1 {
13316 if state.brain != requested {
13317 return Err(invalid_feed(
13318 "legacy checkpoint is not bound to the requested brain id",
13319 ));
13320 }
13321 state.requested = requested.to_string();
13322 } else if state.requested != requested {
13323 return Err(invalid_feed(
13324 "local identity/feed checkpoint is bound to a different requested ref",
13325 ));
13326 }
13327 Ok(Some(state))
13328}
13329
13330#[cfg(not(any(unix, windows)))]
13331fn load_trust_in(
13332 _cfg: &HubConfig,
13333 _directory: &TrustDirectory,
13334 _brain: &str,
13335) -> LinkResult<Option<TrustState>> {
13336 Err(LinkError::UnsupportedPlatform {
13337 operation: "verified link.md state",
13338 })
13339}
13340
13341#[cfg(all(test, any(unix, windows)))]
13342fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
13343 let directory = open_trust_dir(cfg)?;
13344 load_trust_in(cfg, &directory, requested)
13345}
13346
13347#[cfg(unix)]
13348fn save_trust_in(
13349 cfg: &HubConfig,
13350 directory: &TrustDirectory,
13351 state: &TrustState,
13352) -> LinkResult<()> {
13353 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13354
13355 let name_string = trust_file_name(cfg, &state.requested)?;
13356 let name = c_name(name_string.as_bytes(), &name_string)?;
13357 let mut bytes = serde_json::to_vec(state)
13358 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13359 bytes.push(b'\n');
13360
13361 let nonce = std::time::SystemTime::now()
13362 .duration_since(std::time::UNIX_EPOCH)
13363 .unwrap_or_default()
13364 .as_nanos();
13365 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13366 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13367 let fd = unsafe {
13368 libc::openat(
13369 directory.as_raw_fd(),
13370 temp.as_ptr(),
13371 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13372 0o600,
13373 )
13374 };
13375 if fd < 0 {
13376 return Err(std::io::Error::last_os_error().into());
13377 }
13378 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13379 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13380 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13381 return Err(error.into());
13382 }
13383 drop(file);
13384 if unsafe {
13385 libc::renameat(
13386 directory.as_raw_fd(),
13387 temp.as_ptr(),
13388 directory.as_raw_fd(),
13389 name.as_ptr(),
13390 )
13391 } != 0
13392 {
13393 let error = std::io::Error::last_os_error();
13394 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13395 return Err(error.into());
13396 }
13397 directory.sync_all()?;
13398 Ok(())
13399}
13400
13401#[cfg(windows)]
13402fn save_trust_in(
13403 cfg: &HubConfig,
13404 directory: &TrustDirectory,
13405 state: &TrustState,
13406) -> LinkResult<()> {
13407 let name = trust_file_name(cfg, &state.requested)?;
13408 let mut bytes = serde_json::to_vec(state)
13409 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13410 bytes.push(b'\n');
13411 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13412 Ok(())
13413}
13414
13415#[cfg(not(any(unix, windows)))]
13416fn save_trust_in(
13417 _cfg: &HubConfig,
13418 _directory: &TrustDirectory,
13419 _state: &TrustState,
13420) -> LinkResult<()> {
13421 Err(LinkError::UnsupportedPlatform {
13422 operation: "verified link.md state",
13423 })
13424}
13425
13426#[cfg(unix)]
13427fn load_alias_in(
13428 cfg: &HubConfig,
13429 directory: &TrustDirectory,
13430 requested: &str,
13431) -> LinkResult<Option<AliasBinding>> {
13432 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13433
13434 let name_string = alias_file_name(cfg, requested)?;
13435 let name = c_name(name_string.as_bytes(), &name_string)?;
13436 let fd = unsafe {
13437 libc::openat(
13438 directory.as_raw_fd(),
13439 name.as_ptr(),
13440 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13441 )
13442 };
13443 if fd < 0 {
13444 let error = std::io::Error::last_os_error();
13445 if error.kind() == std::io::ErrorKind::NotFound {
13446 return Ok(None);
13447 }
13448 return Err(LinkError::UnsafePath { path: name_string });
13449 }
13450 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13451 if !file.metadata()?.is_file() {
13452 return Err(LinkError::UnsafePath { path: name_string });
13453 }
13454 let mut bytes = Vec::new();
13455 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
13456 if bytes.len() > 64 * 1024 {
13457 return Err(invalid_feed("local alias binding is oversized"));
13458 }
13459 let alias: AliasBinding = serde_json::from_slice(&bytes)
13460 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13461 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13462 {
13463 return Err(invalid_feed(
13464 "local alias binding does not match this hub and requested ref",
13465 ));
13466 }
13467 Ok(Some(alias))
13468}
13469
13470#[cfg(windows)]
13471fn load_alias_in(
13472 cfg: &HubConfig,
13473 directory: &TrustDirectory,
13474 requested: &str,
13475) -> LinkResult<Option<AliasBinding>> {
13476 let name = alias_file_name(cfg, requested)?;
13477 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13478 let bytes = match reader.read(Path::new(&name), 64 * 1024) {
13479 Ok(bytes) => bytes,
13480 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13481 Err(_) => return Err(LinkError::UnsafePath { path: name }),
13482 };
13483 let alias: AliasBinding = serde_json::from_slice(&bytes)
13484 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13485 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13486 {
13487 return Err(invalid_feed(
13488 "local alias binding does not match this hub and requested ref",
13489 ));
13490 }
13491 Ok(Some(alias))
13492}
13493
13494#[cfg(not(any(unix, windows)))]
13495fn load_alias_in(
13496 _cfg: &HubConfig,
13497 _directory: &TrustDirectory,
13498 _requested: &str,
13499) -> LinkResult<Option<AliasBinding>> {
13500 Err(LinkError::UnsupportedPlatform {
13501 operation: "verified link.md state",
13502 })
13503}
13504
13505#[cfg(unix)]
13506fn save_alias_in(
13507 cfg: &HubConfig,
13508 directory: &TrustDirectory,
13509 alias: &AliasBinding,
13510) -> LinkResult<()> {
13511 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13512
13513 let name_string = alias_file_name(cfg, &alias.requested)?;
13514 let name = c_name(name_string.as_bytes(), &name_string)?;
13515 let mut bytes = serde_json::to_vec(alias)
13516 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13517 bytes.push(b'\n');
13518 let nonce = std::time::SystemTime::now()
13519 .duration_since(std::time::UNIX_EPOCH)
13520 .unwrap_or_default()
13521 .as_nanos();
13522 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13523 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13524 let fd = unsafe {
13525 libc::openat(
13526 directory.as_raw_fd(),
13527 temp.as_ptr(),
13528 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13529 0o600,
13530 )
13531 };
13532 if fd < 0 {
13533 return Err(std::io::Error::last_os_error().into());
13534 }
13535 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13536 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13537 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13538 return Err(error.into());
13539 }
13540 drop(file);
13541 if unsafe {
13542 libc::renameat(
13543 directory.as_raw_fd(),
13544 temp.as_ptr(),
13545 directory.as_raw_fd(),
13546 name.as_ptr(),
13547 )
13548 } != 0
13549 {
13550 let error = std::io::Error::last_os_error();
13551 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13552 return Err(error.into());
13553 }
13554 directory.sync_all()?;
13555 Ok(())
13556}
13557
13558#[cfg(windows)]
13559fn save_alias_in(
13560 cfg: &HubConfig,
13561 directory: &TrustDirectory,
13562 alias: &AliasBinding,
13563) -> LinkResult<()> {
13564 let name = alias_file_name(cfg, &alias.requested)?;
13565 let mut bytes = serde_json::to_vec(alias)
13566 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13567 bytes.push(b'\n');
13568 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13569 Ok(())
13570}
13571
13572#[cfg(not(any(unix, windows)))]
13573fn save_alias_in(
13574 _cfg: &HubConfig,
13575 _directory: &TrustDirectory,
13576 _alias: &AliasBinding,
13577) -> LinkResult<()> {
13578 Err(LinkError::UnsupportedPlatform {
13579 operation: "verified link.md state",
13580 })
13581}
13582
13583fn load_canonical_pin(
13588 cfg: &HubConfig,
13589 directory: &TrustDirectory,
13590 requested: &str,
13591 resolved_brain: &str,
13592) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
13593 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
13594 if requested == resolved_brain {
13595 return Ok((canonical, None));
13596 }
13597
13598 let mut alias = load_alias_in(cfg, directory, requested)?;
13599 if let Some(binding) = &alias {
13600 if binding.brain != resolved_brain {
13601 return Err(LinkError::AliasRebindRequired {
13602 alias: requested.to_string(),
13603 from: binding.brain.clone(),
13604 to: resolved_brain.to_string(),
13605 });
13606 }
13607 return Ok((canonical, alias));
13608 }
13609
13610 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
13614 if legacy.brain != resolved_brain {
13615 return Err(invalid_feed(
13616 "legacy alias checkpoint names a different canonical brain",
13617 ));
13618 }
13619 if let Some(existing) = &canonical {
13620 if existing.brain != legacy.brain
13621 || existing.anchor != legacy.anchor
13622 || existing.current != legacy.current
13623 || existing.head_seq != legacy.head_seq
13624 || existing.feed_hash != legacy.feed_hash
13625 || existing.rotations != legacy.rotations
13626 {
13627 return Err(invalid_feed(
13628 "legacy alias checkpoint conflicts with the canonical checkpoint",
13629 ));
13630 }
13631 } else {
13632 let mut promoted = legacy.clone();
13633 promoted.requested = resolved_brain.to_string();
13634 promoted.home = None;
13635 save_trust_in(cfg, directory, &promoted)?;
13636 canonical = Some(promoted);
13637 }
13638 alias = Some(AliasBinding {
13639 v: 1,
13640 origin: normalized_origin(&cfg.hub)?,
13641 requested: requested.to_string(),
13642 brain: resolved_brain.to_string(),
13643 home: legacy.home,
13644 });
13645 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
13646 }
13647 Ok((canonical, alias))
13648}
13649
13650pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
13655 require_hardened_filesystem("verified alias rebind")?;
13656 require_safe_ref(alias)?;
13657 require_safe_ref(from)?;
13658 require_safe_ref(to)?;
13659 if crate::ulid::is_ulid(alias)
13660 || !crate::ulid::is_ulid(from)
13661 || !crate::ulid::is_ulid(to)
13662 || from == to
13663 {
13664 return Err(LinkError::InvalidPack {
13665 message:
13666 "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
13667 .to_string(),
13668 });
13669 }
13670
13671 let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
13672 message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
13673 })?;
13674 accept_v2_head(cfg, &verified)?;
13675
13676 let alias_response = ensure_ok(
13677 request(
13678 cfg,
13679 "GET",
13680 &format!("/api/hub/brains/{alias}/v2/head"),
13681 None,
13682 Auth::Required,
13683 )?,
13684 "resolve alias for explicit rebind",
13685 )?;
13686 let resolved: V2HeadResponse = serde_json::from_value(alias_response)
13687 .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
13688 if resolved.v != 2 || resolved.brain_id != to {
13689 return Err(LinkError::RemoteAdvancedDuringSync);
13690 }
13691
13692 let directory = open_trust_dir(cfg)?;
13693 let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
13694 let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
13695 message: "the requested alias has no existing local binding to replace".to_string(),
13696 })?;
13697 if binding.brain != from {
13698 return Err(LinkError::AliasRebindRequired {
13699 alias: alias.to_string(),
13700 from: binding.brain,
13701 to: to.to_string(),
13702 });
13703 }
13704 save_alias_in(
13705 cfg,
13706 &directory,
13707 &AliasBinding {
13708 v: 1,
13709 origin: normalized_origin(&cfg.hub)?,
13710 requested: alias.to_string(),
13711 brain: to.to_string(),
13712 home: binding.home,
13713 },
13714 )?;
13715 Ok(json!({
13716 "v": 2,
13717 "alias": alias,
13718 "from": from,
13719 "to": to,
13720 "outcome": "alias_rebound",
13721 }))
13722}
13723
13724fn save_canonical_pin_and_alias(
13725 cfg: &HubConfig,
13726 directory: &TrustDirectory,
13727 requested: &str,
13728 resolved_brain: &str,
13729 mut state: TrustState,
13730 existing_alias: Option<&AliasBinding>,
13731) -> LinkResult<()> {
13732 state.requested = resolved_brain.to_string();
13733 state.brain = resolved_brain.to_string();
13734 state.home = None;
13735 save_trust_in(cfg, directory, &state)?;
13736 if requested != resolved_brain {
13737 save_alias_in(
13738 cfg,
13739 directory,
13740 &AliasBinding {
13741 v: 1,
13742 origin: normalized_origin(&cfg.hub)?,
13743 requested: requested.to_string(),
13744 brain: resolved_brain.to_string(),
13745 home: existing_alias.and_then(|alias| alias.home.clone()),
13746 },
13747 )?;
13748 }
13749 Ok(())
13750}
13751
13752fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
13753 const ED25519_SPKI_PREFIX: &[u8] = &[
13754 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13755 ];
13756 let entry = &item.entry;
13757 let public_der = URL_SAFE_NO_PAD
13758 .decode(&entry.public_key)
13759 .map_err(|_| invalid_feed("public key is not base64url"))?;
13760 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
13761 || !public_der.starts_with(ED25519_SPKI_PREFIX)
13762 {
13763 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
13764 }
13765 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
13766 if entry.brain != format!("ed25519:{fingerprint}") {
13767 return Err(invalid_feed(
13768 "brain fingerprint does not match its public key",
13769 ));
13770 }
13771 let _ = verify_identity_chain(identity, None)?;
13773 let mut chain: Vec<(&str, &str)> = identity
13774 .previous
13775 .iter()
13776 .rev()
13777 .map(|previous| {
13778 (
13779 previous.fingerprint.as_str(),
13780 previous.public_key_spki.as_str(),
13781 )
13782 })
13783 .collect();
13784 chain.push((&identity.fingerprint, &identity.public_key_spki));
13785 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
13786 *known_fingerprint == fingerprint && *spki == entry.public_key
13787 });
13788 let Some(signer_index) = signer_index else {
13789 return Err(invalid_feed(
13790 "entry signer is not this brain's identity (current or rotated-from)",
13791 ));
13792 };
13793 let lower_boundary = if signer_index == 0 {
13794 None
13795 } else {
13796 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
13797 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13798 Some(prior.prior_head_seq)
13799 };
13800 let upper_boundary = if signer_index == identity.rotations.len() {
13801 None
13802 } else {
13803 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
13804 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13805 Some(next.prior_head_seq)
13806 };
13807 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
13808 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
13809 {
13810 return Err(invalid_feed(
13811 "entry signer is outside its authenticated rotation epoch",
13812 ));
13813 }
13814 let unsigned = UnsignedFeedEntry {
13815 v: entry.v,
13816 seq: entry.seq,
13817 ts: &entry.ts,
13818 brain: &entry.brain,
13819 public_key: &entry.public_key,
13820 kind: &entry.kind,
13821 op: &entry.op,
13822 pack_sha256: &entry.pack_sha256,
13823 files: &entry.files,
13824 removed: &entry.removed,
13825 prev_entry_hash: &entry.prev_entry_hash,
13826 };
13827 let message =
13828 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
13829 let signature = URL_SAFE_NO_PAD
13830 .decode(&entry.sig)
13831 .map_err(|_| invalid_feed("signature is not base64url"))?;
13832 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
13833 .verify(&message, &signature)
13834 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
13835
13836 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
13837 exact.push(b'\n');
13838 let actual_hash = format!("{:x}", Sha256::digest(&exact));
13839 if actual_hash != item.hash {
13840 return Err(invalid_feed("entry SHA-256 does not match"));
13841 }
13842 Ok(())
13843}
13844
13845#[derive(Serialize)]
13851struct UnsignedRotation<'a> {
13852 v: u8,
13853 op: &'a str,
13854 brain: &'a str,
13855 public_key: &'a str,
13856 new_brain: &'a str,
13857 new_public_key: &'a str,
13858 prior_head_seq: u64,
13859 prior_feed_hash: Option<&'a str>,
13860 ts: String,
13861}
13862
13863#[derive(Debug, Deserialize, Serialize)]
13868#[serde(deny_unknown_fields)]
13869struct RotationJournal {
13870 v: u8,
13871 origin: String,
13872 brain: String,
13873 old_brain: String,
13874 new_brain: String,
13875 prior_head_seq: u64,
13876 prior_feed_hash: Option<String>,
13877 statement: String,
13878}
13879
13880fn rotation_journal_path(key_path: &Path) -> PathBuf {
13881 let mut path = key_path.as_os_str().to_os_string();
13882 path.push(".rotation.json");
13883 PathBuf::from(path)
13884}
13885
13886fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
13887 #[cfg(unix)]
13888 let file = {
13889 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13890 use std::os::unix::ffi::OsStrExt as _;
13891 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13892 .map_err(|error| {
13893 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
13894 })?;
13895 let leaf_name = path
13896 .file_name()
13897 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
13898 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
13899 let fd = unsafe {
13900 libc::openat(
13901 parent.as_raw_fd(),
13902 leaf.as_ptr(),
13903 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13904 )
13905 };
13906 if fd < 0 {
13907 return Err(bad_agent_key(
13908 "the rotation journal must be an existing regular file without symlink ancestors",
13909 ));
13910 }
13911 unsafe { std::fs::File::from_raw_fd(fd) }
13912 };
13913 #[cfg(not(unix))]
13914 let file = std::fs::File::open(path)
13915 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
13916 let metadata = file
13917 .metadata()
13918 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
13919 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
13920 return Err(bad_agent_key(
13921 "the rotation journal must be a bounded regular file",
13922 ));
13923 }
13924 #[cfg(unix)]
13925 {
13926 use std::os::unix::fs::PermissionsExt as _;
13927 if metadata.permissions().mode() & 0o077 != 0 {
13928 return Err(bad_agent_key(
13929 "the rotation journal is accessible to group/other; set mode 0600",
13930 ));
13931 }
13932 }
13933 serde_json::from_reader(file)
13934 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
13935}
13936
13937fn remove_rotation_journal(path: &Path) {
13938 #[cfg(unix)]
13939 {
13940 use std::os::fd::AsRawFd as _;
13941 use std::os::unix::ffi::OsStrExt as _;
13942 let Ok(parent) =
13943 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13944 else {
13945 return;
13946 };
13947 let Some(leaf_name) = path.file_name() else {
13948 return;
13949 };
13950 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
13951 return;
13952 };
13953 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
13954 let _ = parent.sync_all();
13955 }
13956 }
13957 #[cfg(not(unix))]
13958 {
13959 let _ = std::fs::remove_file(path);
13960 }
13961}
13962
13963fn validate_rotation_journal(
13964 journal: &RotationJournal,
13965 cfg: &HubConfig,
13966 canonical_brain: &str,
13967 old_key: &AgentSigningKey,
13968 new_key: &AgentSigningKey,
13969 head: &Head,
13970) -> LinkResult<()> {
13971 if journal.v != 1
13972 || journal.origin != normalized_origin(&cfg.hub)?
13973 || journal.brain != canonical_brain
13974 || journal.old_brain != old_key.multikey
13975 || journal.new_brain != new_key.multikey
13976 || journal.prior_head_seq != head.seq
13977 || journal.prior_feed_hash != head.feed_hash
13978 {
13979 return Err(invalid_feed(
13980 "rotation journal does not match the verified key and feed boundary",
13981 ));
13982 }
13983 let statement: RotationStatement = serde_json::from_str(&journal.statement)
13984 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
13985 if statement.prior_head_seq != journal.prior_head_seq
13986 || statement.prior_feed_hash != journal.prior_feed_hash
13987 || statement.brain != old_key.multikey
13988 || statement.public_key != old_key.public_key_spki
13989 || statement.new_brain != new_key.multikey
13990 || statement.new_public_key != new_key.public_key_spki
13991 {
13992 return Err(invalid_feed(
13993 "rotation journal statement does not match its durable intent",
13994 ));
13995 }
13996 let identity = FeedIdentity {
13997 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13998 public_key_spki: new_key.public_key_spki.clone(),
13999 previous: vec![PreviousIdentity {
14000 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
14001 public_key_spki: old_key.public_key_spki.clone(),
14002 }],
14003 rotations: vec![journal.statement.clone()],
14004 };
14005 verify_identity_chain(&identity, None)?;
14006 Ok(())
14007}
14008
14009#[derive(Debug, Serialize)]
14011pub struct RotationReport {
14012 pub brain: String,
14014 pub multikey: String,
14016 #[serde(rename = "keyFile")]
14018 pub key_file: String,
14019 pub previous: Vec<String>,
14021}
14022
14023pub fn rotate_brain_key(
14029 cfg: &HubConfig,
14030 brain: &str,
14031 old_key: &AgentSigningKey,
14032 out: &Path,
14033) -> LinkResult<RotationReport> {
14034 require_hardened_filesystem("key rotation")?;
14035 require_safe_ref(brain)?;
14036 let new_key = if out.exists() {
14040 load_signing_key(out)?
14041 } else {
14042 let rng = ring::rand::SystemRandom::new();
14043 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
14044 .map_err(|_| bad_agent_key("key generation failed"))?;
14045 let pair = agent_keypair(pkcs8.as_ref())?;
14046 let (public_key_spki, multikey) = public_identity_for(&pair);
14047 write_secret_new(
14048 out,
14049 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
14050 )?;
14051 AgentSigningKey {
14052 pkcs8: pkcs8.as_ref().to_vec(),
14053 multikey,
14054 public_key_spki,
14055 }
14056 };
14057 let new_spki = new_key.public_key_spki.clone();
14058 let new_multikey = new_key.multikey.clone();
14059 let journal_path = rotation_journal_path(out);
14060 let before_v2 = v2_verified_head(cfg, brain)?;
14061 let (canonical_brain, served_identity, observed_head, v2_profile) =
14062 if let Some(head) = before_v2 {
14063 let observed = Head {
14064 brain: head.brain_id.clone(),
14065 seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14066 updated_at: head
14067 .pointer
14068 .as_ref()
14069 .map(|pointer| pointer.signed_at.clone()),
14070 feed_hash: head
14071 .pointer
14072 .as_ref()
14073 .map(|pointer| pointer.feed_hash.clone()),
14074 verified: true,
14075 };
14076 let identity = v2_identity(&head.identity);
14077 let canonical = head.brain_id.clone();
14078 accept_v2_head(cfg, &head)?;
14079 (canonical, identity, observed, true)
14080 } else {
14081 let remote = verified_remote_head(cfg, brain, false)?;
14082 let identity = remote
14083 .identity
14084 .clone()
14085 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
14086 (remote.head.brain.clone(), identity, remote.head, false)
14087 };
14088 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
14089 let already_rotated = served_multikey == new_multikey;
14090 if already_rotated && !journal_path.exists() {
14095 remove_rotation_journal(&journal_path);
14096 return Ok(RotationReport {
14097 brain: brain.to_string(),
14098 multikey: new_multikey,
14099 key_file: out.display().to_string(),
14100 previous: served_identity
14101 .previous
14102 .iter()
14103 .map(|identity| format!("ed25519:{}", identity.fingerprint))
14104 .collect(),
14105 });
14106 }
14107 if !already_rotated && served_multikey != old_key.multikey {
14108 return Err(invalid_feed(
14109 "the supplied old key is not the brain's verified current identity",
14110 ));
14111 }
14112
14113 let journal = if journal_path.exists() {
14114 read_rotation_journal(&journal_path)?
14115 } else {
14116 let ts = crate::now()
14117 .with_timezone(&chrono::Utc)
14118 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
14119 .to_string();
14120 let unsigned = serde_json::to_string(&UnsignedRotation {
14121 v: 1,
14122 op: "rotate",
14123 brain: &old_key.multikey,
14124 public_key: &old_key.public_key_spki,
14125 new_brain: &new_multikey,
14126 new_public_key: &new_spki,
14127 prior_head_seq: observed_head.seq,
14128 prior_feed_hash: observed_head.feed_hash.as_deref(),
14129 ts,
14130 })
14131 .expect("serialize rotation");
14132 let old_pair = agent_keypair(&old_key.pkcs8)?;
14133 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14134 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
14135 let journal = RotationJournal {
14136 v: 1,
14137 origin: normalized_origin(&cfg.hub)?,
14138 brain: canonical_brain.clone(),
14139 old_brain: old_key.multikey.clone(),
14140 new_brain: new_multikey.clone(),
14141 prior_head_seq: observed_head.seq,
14142 prior_feed_hash: observed_head.feed_hash.clone(),
14143 statement,
14144 };
14145 let mut exact = serde_json::to_vec(&journal)
14146 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
14147 exact.push(b'\n');
14148 if write_secret_new(&journal_path, &exact).is_err() {
14149 read_rotation_journal(&journal_path)?
14152 } else {
14153 journal
14154 }
14155 };
14156 validate_rotation_journal(
14157 &journal,
14158 cfg,
14159 &canonical_brain,
14160 old_key,
14161 &new_key,
14162 &observed_head,
14163 )?;
14164
14165 let body = json!({ "statement": journal.statement });
14166 let path = format!("/api/hub/brains/{brain}/rotate");
14167 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
14168 let attempted_failure = match attempted {
14169 Ok(response) if (200..300).contains(&response.status) => None,
14170 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
14171 Err(error) => Some(error),
14172 };
14173
14174 let identity = if v2_profile {
14178 match v2_verified_head(cfg, brain) {
14179 Ok(Some(after)) => {
14180 let identity = v2_identity(&after.identity);
14181 accept_v2_head(cfg, &after)?;
14182 identity
14183 }
14184 Ok(None) => {
14185 return Err(attempted_failure.unwrap_or_else(|| {
14186 invalid_feed("rotated v2 brain no longer serves a v2 head")
14187 }));
14188 }
14189 Err(error) => return Err(attempted_failure.unwrap_or(error)),
14190 }
14191 } else {
14192 match verified_remote_head(cfg, brain, false) {
14193 Ok(after) => after
14194 .identity
14195 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
14196 Err(error) => return Err(attempted_failure.unwrap_or(error)),
14197 }
14198 };
14199 if format!("ed25519:{}", identity.fingerprint) != new_multikey
14200 || identity.public_key_spki != new_spki
14201 {
14202 return Err(attempted_failure.unwrap_or_else(|| {
14203 invalid_feed("hub acknowledged rotation without committing the verified new identity")
14204 }));
14205 }
14206 if v2_profile {
14207 if let Some(error) = attempted_failure {
14208 return Err(error);
14213 }
14214 }
14215 let previous = identity
14216 .previous
14217 .iter()
14218 .map(|prior| format!("ed25519:{}", prior.fingerprint))
14219 .collect();
14220 remove_rotation_journal(&journal_path);
14221
14222 Ok(RotationReport {
14223 brain: brain.to_string(),
14224 multikey: new_multikey,
14225 key_file: out.display().to_string(),
14226 previous,
14227 })
14228}
14229
14230#[derive(Debug, Serialize)]
14236pub struct MirrorReport {
14237 pub brain: String,
14239 #[serde(rename = "headSeq")]
14241 pub head_seq: u64,
14242 #[serde(rename = "feedHash")]
14244 pub feed_hash: Option<String>,
14245 pub entries: u64,
14247 pub pinned: String,
14249 pub files: usize,
14251}
14252
14253pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
14255
14256#[derive(Debug)]
14258pub struct VerifiedMirrorMaterial {
14259 pub brain: String,
14260 pub head_seq: u64,
14261 pub feed_hash: Option<String>,
14262 pub identity: serde_json::Value,
14263 pub entries: Vec<(u64, String, String)>,
14265 pub pack_sha256: Option<String>,
14266}
14267
14268#[derive(Deserialize)]
14269#[serde(deny_unknown_fields)]
14270struct StoredMirrorHead {
14271 brain: String,
14272 #[serde(rename = "headSeq")]
14273 head_seq: u64,
14274 #[serde(rename = "feedHash")]
14275 feed_hash: Option<String>,
14276}
14277
14278pub fn verify_mirror_material(
14281 head_bytes: &[u8],
14282 identity_bytes: &[u8],
14283 feed_bytes: &[Vec<u8>],
14284 snapshot_pack: Option<&[u8]>,
14285 expected_anchor: &str,
14286) -> LinkResult<VerifiedMirrorMaterial> {
14287 let snapshot_hash = snapshot_pack
14288 .filter(|pack| !pack.is_empty())
14289 .map(content_sha256);
14290 verify_mirror_material_with_pack_hash(
14291 head_bytes,
14292 identity_bytes,
14293 feed_bytes,
14294 snapshot_hash.as_deref(),
14295 expected_anchor,
14296 )
14297}
14298
14299pub fn verify_mirror_material_with_pack_hash(
14303 head_bytes: &[u8],
14304 identity_bytes: &[u8],
14305 feed_bytes: &[Vec<u8>],
14306 snapshot_pack_sha256: Option<&str>,
14307 expected_anchor: &str,
14308) -> LinkResult<VerifiedMirrorMaterial> {
14309 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
14310 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
14311 require_safe_ref(&head.brain)?;
14312 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
14313 return Err(invalid_feed(
14314 "stored mirror feed count does not match its bounded head sequence",
14315 ));
14316 }
14317 let aggregate = feed_bytes
14318 .iter()
14319 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
14320 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
14321 if aggregate > MAX_FEED_REPLAY_BYTES {
14322 return Err(invalid_feed(
14323 "stored mirror feed metadata exceeds the aggregate limit",
14324 ));
14325 }
14326 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
14327 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
14328 let anchor = verify_identity_chain(&identity, None)?;
14329 if anchor != expected_anchor {
14330 return Err(invalid_feed(
14331 "stored mirror identity does not descend from the explicitly trusted anchor",
14332 ));
14333 }
14334
14335 let mut entries = Vec::with_capacity(feed_bytes.len());
14336 let mut items = Vec::with_capacity(feed_bytes.len());
14337 let mut previous_hash = None;
14338 let mut pack_sha256 = None;
14339 for (index, bytes) in feed_bytes.iter().enumerate() {
14340 let exact = bytes
14341 .strip_suffix(b"\n")
14342 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
14343 if exact.ends_with(b"\n") {
14344 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
14345 }
14346 let entry: FeedEntry = serde_json::from_slice(exact)
14347 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
14348 let expected_seq = index as u64 + 1;
14349 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
14350 return Err(invalid_feed(
14351 "stored mirror feed is not contiguous and hash-chained",
14352 ));
14353 }
14354 let canonical = serde_json::to_vec(&entry)
14355 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
14356 if canonical != exact {
14357 return Err(invalid_feed(
14358 "stored feed entry is not in normative serialization",
14359 ));
14360 }
14361 let hash = content_sha256(bytes);
14362 let item = FeedItem {
14363 hash: hash.clone(),
14364 entry,
14365 };
14366 verify_feed_item(&item, &identity)?;
14367 previous_hash = Some(hash.clone());
14368 if expected_seq == head.head_seq {
14369 pack_sha256 = Some(item.entry.pack_sha256.clone());
14370 }
14371 entries.push((
14372 expected_seq,
14373 std::str::from_utf8(exact)
14374 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
14375 .to_string(),
14376 hash,
14377 ));
14378 items.push(item);
14379 }
14380 if previous_hash != head.feed_hash {
14381 return Err(invalid_feed(
14382 "stored mirror feed does not converge on its advertised head",
14383 ));
14384 }
14385 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
14386 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
14387 (0, None, None) => {}
14388 (_, Some(actual), Some(expected)) if actual == expected => {}
14389 _ => {
14390 return Err(LinkError::InvalidPack {
14391 message: "stored snapshot pack does not match the signed head digest".to_string(),
14392 });
14393 }
14394 }
14395 let identity_value = serde_json::to_value(&identity)
14396 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
14397 Ok(VerifiedMirrorMaterial {
14398 brain: head.brain,
14399 head_seq: head.head_seq,
14400 feed_hash: head.feed_hash,
14401 identity: identity_value,
14402 entries,
14403 pack_sha256,
14404 })
14405}
14406
14407pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
14410 format!(
14411 "{:x}",
14412 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
14413 )
14414}
14415
14416pub fn content_sha256(bytes: &[u8]) -> String {
14419 format!("{:x}", Sha256::digest(bytes))
14420}
14421
14422pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
14424 let mut digest = Sha256::new();
14425 let mut buffer = [0u8; 64 * 1024];
14426 loop {
14427 let read = reader.read(&mut buffer)?;
14428 if read == 0 {
14429 break;
14430 }
14431 digest.update(&buffer[..read]);
14432 }
14433 Ok(format!("{:x}", digest.finalize()))
14434}
14435
14436#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
14444pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
14445 require_hardened_filesystem("mirror")?;
14446 require_safe_ref(brain)?;
14447 #[cfg(windows)]
14448 {
14449 let _ = (cfg, dest);
14450 return Err(LinkError::UnsupportedPlatform {
14451 operation: "atomic whole-mirror replacement on Windows",
14452 });
14453 }
14454 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
14455 let name = dest
14456 .file_name()
14457 .and_then(|name| name.to_str())
14458 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
14459 .ok_or_else(|| LinkError::UnsafePath {
14460 path: dest.display().to_string(),
14461 })?;
14462 #[cfg(unix)]
14463 let parent_dir = open_or_create_dir_nofollow(parent)?;
14464 #[cfg(unix)]
14465 use std::os::fd::AsRawFd as _;
14466 #[cfg(unix)]
14467 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
14468 #[cfg(unix)]
14469 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
14470 None => false,
14471 Some(true) => true,
14472 Some(false) => {
14473 return Err(LinkError::UnsafePath {
14474 path: dest.display().to_string(),
14475 });
14476 }
14477 };
14478
14479 #[cfg(unix)]
14482 let legacy_backup_name = c_name(
14483 format!(".{name}.dbmd-backup").as_bytes(),
14484 &dest.display().to_string(),
14485 )?;
14486 #[cfg(unix)]
14487 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
14488 return Err(LinkError::UnsafePath {
14489 path: parent
14490 .join(format!(".{name}.dbmd-backup"))
14491 .display()
14492 .to_string(),
14493 });
14494 }
14495
14496 let nonce = std::time::SystemTime::now()
14497 .duration_since(std::time::UNIX_EPOCH)
14498 .unwrap_or_default()
14499 .as_nanos();
14500 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
14501 #[cfg(unix)]
14502 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
14503 #[cfg(unix)]
14504 let stage_dir = create_dir_exclusive_at(
14505 parent_dir.as_raw_fd(),
14506 &stage_name,
14507 &dest.display().to_string(),
14508 )?;
14509
14510 let assembled = (|| -> LinkResult<MirrorReport> {
14511 let remote = verified_remote_head(cfg, brain, true)?;
14512 let brain_id = remote.head.brain.clone();
14513 let identity = remote
14514 .identity
14515 .as_ref()
14516 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
14517 let anchor = remote
14518 .anchor
14519 .clone()
14520 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
14521 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
14522 let snapshot_entries = parse_store_pack(pack.clone())?;
14523 let snapshot_count = snapshot_entries.len();
14524 let mut staged_entries = snapshot_entries;
14525 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
14526 for item in &remote.entries {
14527 let mut exact = serde_json::to_vec(&item.entry)
14528 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
14529 exact.push(b'\n');
14530 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
14531 return Err(invalid_feed(
14532 "serialized mirror entry differs from its verified hash",
14533 ));
14534 }
14535 staged_entries.push((
14536 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
14537 exact,
14538 ));
14539 }
14540 let mut identity_bytes = serde_json::to_vec(identity)
14541 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
14542 identity_bytes.push(b'\n');
14543 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
14544 let mut head_bytes = serde_json::to_vec(&json!({
14545 "brain": brain_id,
14546 "headSeq": remote.head.seq,
14547 "feedHash": remote.head.feed_hash,
14548 }))
14549 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
14550 head_bytes.push(b'\n');
14551 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
14552 staged_entries.push((
14553 CONFIG_REL_PATH.to_string(),
14554 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
14555 ));
14556 #[cfg(unix)]
14557 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
14558
14559 Ok(MirrorReport {
14560 brain: brain_id,
14561 head_seq: remote.head.seq,
14562 feed_hash: remote.head.feed_hash,
14563 entries: remote.entries.len() as u64,
14564 pinned: anchor,
14565 files: snapshot_count,
14566 })
14567 })();
14568
14569 let report = match assembled {
14570 Ok(report) => report,
14571 Err(error) => {
14572 #[cfg(unix)]
14573 let _ = remove_tree_at(
14574 parent_dir.as_raw_fd(),
14575 &stage_name,
14576 &dest.display().to_string(),
14577 );
14578 return Err(error);
14579 }
14580 };
14581
14582 #[cfg(unix)]
14583 if let Err(error) =
14584 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
14585 {
14586 let _ = remove_tree_at(
14587 parent_dir.as_raw_fd(),
14588 &stage_name,
14589 &dest.display().to_string(),
14590 );
14591 return Err(error);
14592 }
14593 #[cfg(unix)]
14596 if dest_exists {
14597 remove_tree_at(
14598 parent_dir.as_raw_fd(),
14599 &stage_name,
14600 &dest.display().to_string(),
14601 )?;
14602 }
14603 #[cfg(unix)]
14604 parent_dir.sync_all()?;
14605 Ok(report)
14606}
14607
14608fn verified_remote_head(
14609 cfg: &HubConfig,
14610 brain: &str,
14611 require_full_chain: bool,
14612) -> LinkResult<VerifiedRemote> {
14613 require_hardened_filesystem("verified link.md state")?;
14614 require_safe_ref(brain)?;
14615 let trust_directory = open_trust_dir(cfg)?;
14619 let path = format!("/api/hub/brains/{brain}");
14620 let body = ensure_ok(
14621 request(cfg, "GET", &path, None, Auth::Required)?,
14622 "subscribe",
14623 )?;
14624 let resolved_brain = body
14625 .get("id")
14626 .and_then(Value::as_str)
14627 .filter(|id| crate::ulid::is_ulid(id))
14628 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
14629 .to_string();
14630 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
14631 return Err(invalid_feed(
14632 "brain card id differs from the explicitly requested brain id",
14633 ));
14634 }
14635 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
14640 let (pinned, alias_binding) =
14641 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
14642 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
14643 let advertised_hash = body
14644 .get("feedHash")
14645 .and_then(Value::as_str)
14646 .map(str::to_string);
14647 let updated_at = body
14648 .get("updatedAt")
14649 .and_then(Value::as_str)
14650 .map(str::to_string);
14651 if let Some(pin) = &pinned {
14652 if seq < pin.head_seq {
14653 return Err(invalid_feed(format!(
14654 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
14655 pin.head_seq
14656 )));
14657 }
14658 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
14659 return Err(invalid_feed(
14660 "feed equivocation: the checkpoint sequence now has a different hash",
14661 ));
14662 }
14663 }
14664 if seq == 0 {
14665 if advertised_hash.is_some() {
14666 return Err(invalid_feed("an empty feed advertised a head hash"));
14667 }
14668 let identity: FeedIdentity = serde_json::from_value(
14669 body.get("identity")
14670 .cloned()
14671 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
14672 )
14673 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
14674 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
14675 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
14680 save_canonical_pin_and_alias(
14681 cfg,
14682 &trust_directory,
14683 brain,
14684 &resolved_brain,
14685 TrustState {
14686 v: 2,
14687 origin: normalized_origin(&cfg.hub)?,
14688 requested: resolved_brain.clone(),
14689 brain: resolved_brain.clone(),
14690 home: None,
14691 anchor: anchor.clone(),
14692 current: format!("ed25519:{}", identity.fingerprint),
14693 head_seq: 0,
14694 feed_hash: None,
14695 rotations: identity.rotations.clone(),
14696 hub_signer: None,
14697 protocol_profile: None,
14698 },
14699 alias_binding.as_ref(),
14700 )?;
14701 return Ok(VerifiedRemote {
14702 head: Head {
14703 brain: resolved_brain,
14704 seq,
14705 updated_at,
14706 feed_hash: None,
14707 verified: true,
14708 },
14709 identity: Some(identity),
14710 head_entry: None,
14711 entries: Vec::new(),
14712 anchor: Some(anchor),
14713 });
14714 }
14715 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
14716 return Err(invalid_feed(
14717 "non-empty feed did not advertise a valid SHA-256 head",
14718 ));
14719 }
14720
14721 let replay_head_only = !require_full_chain
14725 && pinned
14726 .as_ref()
14727 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
14728 let mut after = if replay_head_only {
14729 seq - 1
14730 } else if require_full_chain || pinned.is_none() {
14731 0
14732 } else {
14733 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
14734 };
14735 let mut expected_seq = after + 1;
14736 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
14737 None
14738 } else {
14739 pinned
14740 .as_ref()
14741 .and_then(|checkpoint| checkpoint.feed_hash.clone())
14742 };
14743 let mut identity: Option<FeedIdentity> = None;
14744 let mut anchor: Option<String> = None;
14745 let mut head_entry: Option<FeedItem> = None;
14746 let mut all_entries = Vec::new();
14747 let mut observed_entries = Vec::new();
14748 let replay_count = seq
14749 .checked_sub(after)
14750 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
14751 if replay_count > MAX_FEED_REPLAY_ENTRIES {
14752 return Err(invalid_feed(format!(
14753 "feed replay requires {replay_count} entries, over the client cap"
14754 )));
14755 }
14756 let mut replay_bytes = 0u64;
14757
14758 loop {
14759 let feed_bytes = ensure_raw_ok(
14760 request_raw(
14761 cfg,
14762 "GET",
14763 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
14764 None,
14765 Auth::Required,
14766 MAX_FEED_RESPONSE_BYTES,
14767 )?,
14768 "subscribe feed",
14769 )?;
14770 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
14771 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
14772 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
14773 return Err(invalid_feed("brain card and feed head disagree"));
14774 }
14775 if feed.entries.len() > FEED_PAGE_LIMIT {
14776 return Err(invalid_feed("feed page exceeds the requested entry limit"));
14777 }
14778 if feed.scope_limited {
14779 if require_full_chain {
14780 return Err(invalid_feed(
14781 "path-scoped grants cannot verify a full snapshot chain",
14782 ));
14783 }
14784 return Ok(VerifiedRemote {
14785 head: Head {
14786 brain: resolved_brain,
14787 seq,
14788 updated_at,
14789 feed_hash: advertised_hash,
14790 verified: false,
14791 },
14792 identity: None,
14793 head_entry: None,
14794 entries: Vec::new(),
14795 anchor: None,
14796 });
14797 }
14798 let page_identity = feed
14799 .identity
14800 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14801 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
14802 if identity
14803 .as_ref()
14804 .is_some_and(|existing| existing != &page_identity)
14805 {
14806 return Err(invalid_feed("identity changed while reading the feed"));
14807 }
14808 if anchor
14809 .as_ref()
14810 .is_some_and(|existing| existing != &page_anchor)
14811 {
14812 return Err(invalid_feed(
14813 "identity anchor changed while reading the feed",
14814 ));
14815 }
14816 identity = Some(page_identity.clone());
14817 if anchor.is_none() {
14818 anchor = Some(page_anchor);
14819 }
14820 if feed.entries.is_empty() {
14821 return Err(invalid_feed("feed page was empty before the signed head"));
14822 }
14823
14824 for item in feed.entries {
14825 if item.entry.seq != expected_seq {
14826 return Err(invalid_feed(format!(
14827 "expected entry {expected_seq}, feed served {}",
14828 item.entry.seq
14829 )));
14830 }
14831 if item.entry.seq > seq {
14832 return Err(invalid_feed("feed advanced past the card snapshot"));
14833 }
14834 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
14835 return Err(invalid_feed(format!(
14836 "entry {} does not chain to the local checkpoint",
14837 item.entry.seq
14838 )));
14839 }
14840 verify_feed_item(&item, &page_identity)?;
14841 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
14842 replay_bytes = replay_bytes.saturating_add(
14843 serde_json::to_vec(&item)
14844 .map_err(|_| invalid_feed("could not size feed entry"))?
14845 .len() as u64,
14846 );
14847 if replay_bytes > MAX_FEED_REPLAY_BYTES {
14848 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
14849 }
14850 previous_hash = Some(item.hash.clone());
14851 after = item.entry.seq;
14852 expected_seq = expected_seq
14853 .checked_add(1)
14854 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
14855 if require_full_chain {
14856 all_entries.push(item.clone());
14857 }
14858 observed_entries.push(item.clone());
14859 head_entry = Some(item);
14860 }
14861 if after == seq {
14862 break;
14863 }
14864 }
14865
14866 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
14867 return Err(invalid_feed(
14868 "verified chain does not converge on the advertised head",
14869 ));
14870 }
14871 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14872 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
14873 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
14874 save_canonical_pin_and_alias(
14875 cfg,
14876 &trust_directory,
14877 brain,
14878 &resolved_brain,
14879 TrustState {
14880 v: 2,
14881 origin: normalized_origin(&cfg.hub)?,
14882 requested: resolved_brain.clone(),
14883 brain: resolved_brain.clone(),
14884 home: None,
14885 anchor: anchor.clone(),
14886 current: format!("ed25519:{}", identity.fingerprint),
14887 head_seq: seq,
14888 feed_hash: advertised_hash.clone(),
14889 rotations: identity.rotations.clone(),
14890 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
14891 protocol_profile: pinned
14892 .as_ref()
14893 .and_then(|state| state.protocol_profile.clone()),
14894 },
14895 alias_binding.as_ref(),
14896 )?;
14897 Ok(VerifiedRemote {
14898 head: Head {
14899 brain: resolved_brain,
14900 seq,
14901 updated_at,
14902 feed_hash: advertised_hash,
14903 verified: true,
14904 },
14905 identity: Some(identity),
14906 head_entry,
14907 entries: all_entries,
14908 anchor: Some(anchor),
14909 })
14910}
14911
14912pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
14917 if let Some(verified) = v2_verified_head(cfg, brain)? {
14918 let observation = Head {
14919 brain: verified.brain_id.clone(),
14920 seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14921 updated_at: verified
14922 .pointer
14923 .as_ref()
14924 .map(|pointer| pointer.signed_at.clone()),
14925 feed_hash: verified
14926 .pointer
14927 .as_ref()
14928 .map(|pointer| pointer.feed_hash.clone()),
14929 verified: true,
14930 };
14931 accept_v2_head(cfg, &verified)?;
14932 return Ok(observation);
14933 }
14934 Ok(verified_remote_head(cfg, brain, false)?.head)
14935}
14936
14937#[cfg(test)]
14938mod tests {
14939 use super::*;
14940
14941 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
14942
14943 fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
14944 json!({
14945 "sha256": "a".repeat(64),
14946 "bytes": 10,
14947 "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
14948 })
14949 }
14950
14951 #[test]
14952 fn upload_reservations_batch_by_count_and_by_size() {
14953 let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
14957 let batches = batch_upload_declarations(declarations.clone());
14958
14959 assert!(batches.len() > 1, "5,000 blobs must not ride one request");
14960 for batch in &batches {
14961 assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
14962 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14963 .expect("batch serializes")
14964 .len();
14965 assert!(
14966 bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
14967 "batch body {bytes} exceeds the reservation budget"
14968 );
14969 }
14970 let flattened: Vec<Value> = batches.into_iter().flatten().collect();
14971 assert_eq!(
14972 flattened, declarations,
14973 "batching must preserve the set and order"
14974 );
14975 }
14976
14977 #[test]
14978 fn only_load_shaped_hub_answers_are_worth_asking_again() {
14979 for status in [408, 429, 500, 502, 503, 504] {
14984 assert!(is_retryable_hub_status(status), "{status} is load-shaped");
14985 }
14986 for status in [400, 401, 403, 404, 409, 413, 422] {
14987 assert!(
14988 !is_retryable_hub_status(status),
14989 "{status} states something about the request"
14990 );
14991 }
14992 let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
14994 assert!(total >= 60_000, "backoff totals only {total}ms");
14995 }
14996
14997 #[test]
14998 fn a_batch_shares_a_connection_only_within_one_authority() {
14999 let cfg = HubConfig {
15004 hub: "https://www.sevrahq.com".to_string(),
15005 key: Some("k".to_string()),
15006 agent_key: None,
15007 brain_key: None,
15008 state_dir: PathBuf::from("."),
15009 store_selected: false,
15010 };
15011 assert!(shared_staging_agent(&cfg, &[]).is_none());
15012 assert!(
15013 shared_staging_agent(
15014 &cfg,
15015 &[
15016 "https://one.example.com/a?sig=1",
15017 "https://two.example.com/b?sig=2",
15018 ]
15019 )
15020 .is_none(),
15021 "two authorities must not share a pinned pool"
15022 );
15023 assert!(
15024 shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
15025 "an unsafe object-store URL must not produce an agent"
15026 );
15027 assert!(
15028 shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
15029 "credentials in the URL must not produce an agent"
15030 );
15031 }
15032
15033 #[test]
15034 fn a_staged_change_states_only_operations_and_blobs() {
15035 let operations = vec![json!({
15039 "op": "put",
15040 "path": "records/a.md",
15041 "blob": "a".repeat(64),
15042 "bytes": 3,
15043 })];
15044 let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
15045 let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
15046 let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
15047 let keys: Vec<&str> = parsed
15048 .as_object()
15049 .expect("manifest is an object")
15050 .keys()
15051 .map(String::as_str)
15052 .collect();
15053 assert_eq!(keys, ["blobs", "operations"]);
15054 assert_eq!(parsed["operations"], Value::Array(operations));
15055 assert_eq!(parsed["blobs"], blobs);
15056 }
15057
15058 #[test]
15059 fn a_staged_push_signs_the_change_not_the_transport() {
15060 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15065 let staged = json!({
15066 "mutation_id": "dbmd-1",
15067 "rebase": "strict",
15068 "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
15069 });
15070 let view = v2_signed_request_view(&staged, &operations);
15071 assert_eq!(view["operations"], Value::Array(operations.clone()));
15072 assert!(view.get("staged_change").is_none());
15073 assert_eq!(view["mutation_id"], staged["mutation_id"]);
15074
15075 let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
15076 assert_eq!(v2_signed_request_view(&inline, &[]), inline);
15077 }
15078
15079 #[test]
15080 fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
15081 let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
15082 let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
15083 .expect_err("an oversized change must not be staged");
15084 assert!(
15085 matches!(error, LinkError::PushTooLarge { .. }),
15086 "expected a size refusal, got {error:?}"
15087 );
15088 }
15089
15090 #[test]
15091 fn a_push_that_fits_the_request_is_left_inline() {
15092 let cfg = HubConfig {
15096 hub: "http://127.0.0.1:9".to_string(),
15097 key: Some("k".to_string()),
15098 agent_key: None,
15099 brain_key: None,
15100 state_dir: PathBuf::from("."),
15101 store_selected: false,
15102 };
15103 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15104 let mut body = json!({
15105 "mutation_id": "dbmd-1",
15106 "operations": operations,
15107 "blobs": [],
15108 });
15109 stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
15110 assert!(body.get("staged_change").is_none());
15111 assert_eq!(body["operations"], Value::Array(operations));
15112 }
15113
15114 #[test]
15115 fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
15116 let declarations: Vec<Value> = (0..2_000)
15120 .map(|index| {
15121 json!({
15122 "sha256": "a".repeat(64),
15123 "bytes": 10,
15124 "coordinates": (0..24)
15125 .map(|slot| format!(
15126 "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
15127 ))
15128 .collect::<Vec<_>>(),
15129 })
15130 })
15131 .collect();
15132 let batches = batch_upload_declarations(declarations);
15133 assert!(
15134 batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
15135 "wide coordinate sets must bound the batch by size"
15136 );
15137 for batch in &batches {
15138 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15139 .expect("batch serializes")
15140 .len();
15141 assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
15142 }
15143 }
15144
15145 #[test]
15146 fn a_small_push_still_rides_exactly_one_request() {
15147 let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
15148 assert_eq!(batch_upload_declarations(declarations).len(), 1);
15149 assert!(batch_upload_declarations(Vec::new()).is_empty());
15150 }
15151
15152 #[test]
15153 fn exact_source_move_becomes_one_provenance_preserving_rename() {
15154 let hash = "a".repeat(64);
15155 let operations = vec![
15156 json!({
15157 "op": "put",
15158 "path": "sources/curated/item.md",
15159 "expected": { "kind": "absent" },
15160 "blob": hash,
15161 "bytes": 19,
15162 }),
15163 json!({
15164 "op": "delete",
15165 "path": "sources/inbox/item.md",
15166 "expected": { "kind": "blob", "hash": hash },
15167 }),
15168 ];
15169
15170 assert_eq!(
15171 infer_exact_source_promotions(operations),
15172 vec![json!({
15173 "op": "rename",
15174 "from": "sources/inbox/item.md",
15175 "to": "sources/curated/item.md",
15176 "expected_from": { "kind": "blob", "hash": hash },
15177 "expected_to": { "kind": "absent" },
15178 "blob": hash,
15179 "bytes": 19,
15180 })]
15181 );
15182 }
15183
15184 #[test]
15185 fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
15186 let hash = "b".repeat(64);
15187 let operations = vec![
15188 json!({
15189 "op": "delete",
15190 "path": "sources/inbox/a.md",
15191 "expected": { "kind": "blob", "hash": hash },
15192 }),
15193 json!({
15194 "op": "delete",
15195 "path": "sources/inbox/b.md",
15196 "expected": { "kind": "blob", "hash": hash },
15197 }),
15198 json!({
15199 "op": "put",
15200 "path": "sources/curated/item.md",
15201 "expected": { "kind": "absent" },
15202 "blob": hash,
15203 "bytes": 19,
15204 }),
15205 ];
15206
15207 assert_eq!(
15208 infer_exact_source_promotions(operations.clone()),
15209 operations,
15210 "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
15211 );
15212 }
15213
15214 #[test]
15215 fn accepted_source_promotion_advances_the_local_baseline_exactly() {
15216 let hash = "c".repeat(64);
15217 let mut candidate = std::collections::BTreeMap::from([(
15218 "sources/inbox/item.md".to_string(),
15219 V2BaselineFile {
15220 sha256: hash.clone(),
15221 bytes: 19,
15222 proof: None,
15223 },
15224 )]);
15225 let mut candidate_assets = std::collections::BTreeMap::new();
15226 let operations = vec![
15227 json!({
15228 "op": "rename",
15229 "from": "sources/inbox/item.md",
15230 "to": "sources/curated/item.md",
15231 "expected_from": { "kind": "blob", "hash": hash },
15232 "expected_to": { "kind": "absent" },
15233 "blob": hash,
15234 "bytes": 19,
15235 }),
15236 json!({
15237 "op": "put",
15238 "path": "records/rsvps/item.md",
15239 "expected": { "kind": "absent" },
15240 "blob": "d".repeat(64),
15241 "bytes": 23,
15242 }),
15243 ];
15244
15245 assert!(!apply_generated_v2_operations(
15246 &operations,
15247 &std::collections::BTreeMap::new(),
15248 &mut candidate,
15249 &mut candidate_assets,
15250 )
15251 .unwrap());
15252 assert!(!candidate.contains_key("sources/inbox/item.md"));
15253 assert_eq!(
15254 candidate
15255 .get("sources/curated/item.md")
15256 .map(|file| (&file.sha256, file.bytes)),
15257 Some((&hash, 19))
15258 );
15259 assert_eq!(
15260 candidate
15261 .get("records/rsvps/item.md")
15262 .map(|file| (file.sha256.as_str(), file.bytes)),
15263 Some((
15264 "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
15265 23
15266 ))
15267 );
15268 }
15269
15270 fn merge_fixture(
15271 base: Option<&str>,
15272 remote: Option<&str>,
15273 local: Option<&str>,
15274 keep_local: bool,
15275 ) -> V2PulledMerge<String> {
15276 let map = |value: Option<&str>| {
15277 value
15278 .map(|value| [("records/a.md".to_string(), value.to_string())])
15279 .into_iter()
15280 .flatten()
15281 .collect::<std::collections::BTreeMap<_, _>>()
15282 };
15283 merge_v2_pulled_records(
15284 &map(base),
15285 &map(remote),
15286 &map(local),
15287 |value, _| value.clone(),
15288 |value, _| value.clone(),
15289 |_| keep_local,
15290 )
15291 }
15292
15293 #[test]
15294 fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
15295 let path = "records/a.md".to_string();
15296
15297 let local_add = merge_fixture(None, None, Some("local"), false);
15298 assert_eq!(
15299 local_add.records.get(&path).map(String::as_str),
15300 Some("local")
15301 );
15302 assert!(local_add.accept_remote.is_empty());
15303 assert!(local_add.conflicts.is_empty());
15304
15305 let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
15306 assert_eq!(
15307 local_edit.records.get(&path).map(String::as_str),
15308 Some("local")
15309 );
15310 assert!(local_edit.accept_remote.is_empty());
15311 assert!(local_edit.conflicts.is_empty());
15312
15313 let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
15314 assert!(!local_delete.records.contains_key(&path));
15315 assert!(local_delete.accept_remote.is_empty());
15316 assert!(local_delete.conflicts.is_empty());
15317
15318 let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
15319 assert_eq!(
15320 remote_edit.records.get(&path).map(String::as_str),
15321 Some("remote")
15322 );
15323 assert!(remote_edit.accept_remote.contains(&path));
15324 assert!(remote_edit.conflicts.is_empty());
15325
15326 let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
15327 assert!(!remote_delete.records.contains_key(&path));
15328 assert!(remote_delete.accept_remote.contains(&path));
15329 assert!(remote_delete.conflicts.is_empty());
15330
15331 let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
15332 assert_eq!(
15333 same_edit.records.get(&path).map(String::as_str),
15334 Some("same")
15335 );
15336 assert!(same_edit.accept_remote.contains(&path));
15337 assert!(same_edit.conflicts.is_empty());
15338
15339 let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
15340 assert_eq!(conflict.conflicts, vec![path.clone()]);
15341 assert_eq!(
15342 conflict.records.get(&path).map(String::as_str),
15343 Some("local")
15344 );
15345 assert!(conflict.accept_remote.is_empty());
15346
15347 let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
15348 assert_eq!(
15349 kept_home.records.get(&path).map(String::as_str),
15350 Some("local")
15351 );
15352 assert!(kept_home.accept_remote.is_empty());
15353 assert!(kept_home.conflicts.is_empty());
15354 }
15355
15356 #[test]
15357 fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
15358 let path = "sources/report.pdf";
15359 let record = crate::AssetRecord {
15360 path: path.to_string(),
15361 sha256: "a".repeat(64),
15362 bytes: 42,
15363 media_type: "application/pdf".to_string(),
15364 wrappers: vec!["gzip".to_string()],
15365 required: true,
15366 };
15367 let mut remote = V2BaselineAsset {
15368 blob_sha256: record.sha256.clone(),
15369 bytes: record.bytes,
15370 media_type: record.media_type.clone(),
15371 wrappers: record.wrappers.clone(),
15372 required: record.required,
15373 disposition: "withheld".to_string(),
15374 leaf_hash: "b".repeat(64),
15375 };
15376
15377 assert!(v2_asset_resumes_hosting(
15378 Some(&remote),
15379 path,
15380 &record,
15381 "hosted"
15382 ));
15383 assert!(!v2_asset_resumes_hosting(
15384 Some(&remote),
15385 path,
15386 &record,
15387 "withheld"
15388 ));
15389
15390 remote.disposition = "hosted".to_string();
15391 assert!(!v2_asset_resumes_hosting(
15392 Some(&remote),
15393 path,
15394 &record,
15395 "hosted"
15396 ));
15397
15398 remote.disposition = "withheld".to_string();
15399 remote.blob_sha256 = "c".repeat(64);
15400 assert!(!v2_asset_resumes_hosting(
15401 Some(&remote),
15402 path,
15403 &record,
15404 "hosted"
15405 ));
15406 assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
15407 }
15408
15409 #[test]
15410 fn v2_fresh_clone_preserves_only_exact_inherited_withheld_asset_absence() {
15411 let path = "sources/report.pdf";
15412 let record = crate::AssetRecord {
15413 path: path.to_string(),
15414 sha256: "a".repeat(64),
15415 bytes: 42,
15416 media_type: "application/pdf".to_string(),
15417 wrappers: vec!["records/report.md".to_string()],
15418 required: true,
15419 };
15420 let mut base = V2BaselineAsset {
15421 blob_sha256: record.sha256.clone(),
15422 bytes: record.bytes,
15423 media_type: record.media_type.clone(),
15424 wrappers: record.wrappers.clone(),
15425 required: record.required,
15426 disposition: "withheld".to_string(),
15427 leaf_hash: "b".repeat(64),
15428 };
15429
15430 assert!(v2_asset_inherits_withheld_absence(
15431 Some(&base),
15432 Some(&record),
15433 Some(&record),
15434 false,
15435 ));
15436 assert!(!v2_asset_inherits_withheld_absence(
15437 Some(&base),
15438 Some(&record),
15439 Some(&record),
15440 true,
15441 ));
15442
15443 base.disposition = "hosted".to_string();
15444 assert!(!v2_asset_inherits_withheld_absence(
15445 Some(&base),
15446 Some(&record),
15447 Some(&record),
15448 false,
15449 ));
15450
15451 base.disposition = "withheld".to_string();
15452 let mut changed = record.clone();
15453 changed.bytes += 1;
15454 assert!(!v2_asset_inherits_withheld_absence(
15455 Some(&base),
15456 Some(&record),
15457 Some(&changed),
15458 false,
15459 ));
15460 assert!(!v2_asset_inherits_withheld_absence(
15461 None,
15462 None,
15463 Some(&record),
15464 false,
15465 ));
15466 }
15467
15468 #[test]
15469 fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
15470 let path = "records/team/alpha.md".to_string();
15471 let deleted_path = "records/team/deleted.md".to_string();
15472 let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
15473 sha256,
15474 bytes,
15475 file: None,
15476 };
15477 let files = vec![
15478 V2ConflictFile {
15479 path: path.clone(),
15480 base: coordinate(None, None),
15481 local: coordinate(Some("b".repeat(64)), Some(7)),
15482 remote: coordinate(Some("a".repeat(64)), Some(5)),
15483 },
15484 V2ConflictFile {
15485 path: deleted_path.clone(),
15486 base: coordinate(Some("c".repeat(64)), Some(9)),
15487 local: coordinate(Some("d".repeat(64)), Some(11)),
15488 remote: coordinate(None, None),
15489 },
15490 ];
15491 let proven = V2BaselineFile {
15492 sha256: "a".repeat(64),
15493 bytes: 5,
15494 proof: None,
15495 };
15496 let current = [(path.clone(), proven.clone())]
15497 .into_iter()
15498 .collect::<std::collections::BTreeMap<_, _>>();
15499
15500 let (selected, deleted) = v2_take_remote_selection(&files, ¤t).unwrap();
15501 assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
15502 assert_eq!(deleted, vec![deleted_path.clone()]);
15503
15504 let changed = [(
15505 path.clone(),
15506 V2BaselineFile {
15507 sha256: "e".repeat(64),
15508 bytes: 5,
15509 proof: None,
15510 },
15511 )]
15512 .into_iter()
15513 .collect::<std::collections::BTreeMap<_, _>>();
15514 assert!(v2_take_remote_selection(&files, &changed).is_err());
15515
15516 let resurrected = [
15517 (path, proven),
15518 (
15519 deleted_path,
15520 V2BaselineFile {
15521 sha256: "f".repeat(64),
15522 bytes: 13,
15523 proof: None,
15524 },
15525 ),
15526 ]
15527 .into_iter()
15528 .collect::<std::collections::BTreeMap<_, _>>();
15529 assert!(v2_take_remote_selection(&files, &resurrected).is_err());
15530 }
15531
15532 #[cfg(target_os = "linux")]
15533 #[test]
15534 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
15535 use std::os::fd::AsRawFd as _;
15536
15537 let sandbox = tempfile::TempDir::new().unwrap();
15538 let parent = std::fs::File::open(sandbox.path()).unwrap();
15539 let stage = std::ffi::CString::new("stage").unwrap();
15540 let destination = std::ffi::CString::new("brain").unwrap();
15541
15542 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15543 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
15544 install_stage_at(
15545 parent.as_raw_fd(),
15546 stage.as_c_str(),
15547 destination.as_c_str(),
15548 false,
15549 )
15550 .unwrap();
15551 assert!(!sandbox.path().join("stage").exists());
15552 assert_eq!(
15553 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15554 b"created"
15555 );
15556
15557 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15558 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
15559 install_stage_at(
15560 parent.as_raw_fd(),
15561 stage.as_c_str(),
15562 destination.as_c_str(),
15563 true,
15564 )
15565 .unwrap();
15566 assert_eq!(
15567 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15568 b"replacement"
15569 );
15570 assert_eq!(
15571 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
15572 b"created",
15573 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
15574 );
15575 }
15576
15577 struct SignedRemoteFixture {
15578 card: String,
15579 feed: String,
15580 key: AgentSigningKey,
15581 identity: FeedIdentity,
15582 }
15583
15584 fn signed_remote_fixture() -> SignedRemoteFixture {
15585 let rng = ring::rand::SystemRandom::new();
15586 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15587 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15588 let (public_key, multikey) = public_identity_for(&pair);
15589 let identity = FeedIdentity {
15590 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15591 public_key_spki: public_key.clone(),
15592 previous: Vec::new(),
15593 rotations: Vec::new(),
15594 };
15595 let mut entry = FeedEntry {
15596 v: 1,
15597 seq: 1,
15598 ts: "2026-07-30T12:00:00.000Z".to_string(),
15599 brain: multikey.clone(),
15600 public_key: public_key.clone(),
15601 kind: "push".to_string(),
15602 op: "snapshot".to_string(),
15603 pack_sha256: "a".repeat(64),
15604 files: Vec::new(),
15605 removed: Vec::new(),
15606 prev_entry_hash: None,
15607 sig: String::new(),
15608 };
15609 let unsigned = UnsignedFeedEntry {
15610 v: entry.v,
15611 seq: entry.seq,
15612 ts: &entry.ts,
15613 brain: &entry.brain,
15614 public_key: &entry.public_key,
15615 kind: &entry.kind,
15616 op: &entry.op,
15617 pack_sha256: &entry.pack_sha256,
15618 files: &entry.files,
15619 removed: &entry.removed,
15620 prev_entry_hash: &entry.prev_entry_hash,
15621 };
15622 entry.sig =
15623 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15624 let mut exact = serde_json::to_vec(&entry).unwrap();
15625 exact.push(b'\n');
15626 let hash = content_sha256(&exact);
15627 let card = json!({
15628 "id": TEST_BRAIN_ID,
15629 "headSeq": 1,
15630 "feedHash": hash,
15631 "identity": identity.clone(),
15632 })
15633 .to_string();
15634 let feed = json!({
15635 "headSeq": 1,
15636 "feedHash": hash,
15637 "identity": identity.clone(),
15638 "entries": [{"hash": hash, "entry": entry}],
15639 "scopeLimited": false,
15640 })
15641 .to_string();
15642 SignedRemoteFixture {
15643 card,
15644 feed,
15645 key: AgentSigningKey {
15646 pkcs8: pkcs8.as_ref().to_vec(),
15647 multikey,
15648 public_key_spki: public_key,
15649 },
15650 identity,
15651 }
15652 }
15653
15654 #[test]
15655 fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
15656 let file = |path: &str, byte: char| FeedFile {
15657 path: path.to_string(),
15658 sha256: byte.to_string().repeat(64),
15659 bytes: 1,
15660 };
15661 let a0 = file("records/a.md", 'a');
15662 let a1 = file("records/a.md", 'b');
15663 let stable = file("records/stable.md", 'c');
15664 let added = file("records/added.md", 'd');
15665 let removed_file = file("records/removed.md", 'e');
15666 let previous = vec![a0, stable.clone(), removed_file.clone()];
15667 let resulting = vec![a1.clone(), stable.clone(), added.clone()];
15668 let removed = vec![removed_file.path.clone()];
15669
15670 assert_eq!(
15671 verify_v1_manifest_disclosure(
15672 "edit",
15673 &previous,
15674 &resulting,
15675 &[a1.clone(), added.clone()],
15676 &removed,
15677 ),
15678 Ok(())
15679 );
15680 assert_eq!(
15681 verify_v1_manifest_disclosure(
15682 "edit",
15683 &previous,
15684 &resulting,
15685 &[stable.clone(), added.clone(), a1.clone()],
15686 &removed,
15687 ),
15688 Ok(())
15689 );
15690 assert_eq!(
15691 verify_v1_manifest_disclosure(
15692 "edit",
15693 &previous,
15694 &resulting,
15695 std::slice::from_ref(&added),
15696 &removed,
15697 ),
15698 Err(V1DisclosureError::EditMissingChange)
15699 );
15700 assert_eq!(
15701 verify_v1_manifest_disclosure(
15702 "edit",
15703 &previous,
15704 &resulting,
15705 &[file("records/a.md", 'f'), added.clone()],
15706 &removed,
15707 ),
15708 Err(V1DisclosureError::EditFalseFile)
15709 );
15710 assert_eq!(
15711 verify_v1_manifest_disclosure(
15712 "edit",
15713 &previous,
15714 &resulting,
15715 &[a1.clone(), added.clone()],
15716 &[],
15717 ),
15718 Err(V1DisclosureError::RemovedMismatch)
15719 );
15720 assert_eq!(
15721 verify_v1_manifest_disclosure(
15722 "push",
15723 &previous,
15724 &resulting,
15725 &[added.clone(), stable, a1],
15726 &removed,
15727 ),
15728 Ok(())
15729 );
15730 assert_eq!(
15731 verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
15732 Err(V1DisclosureError::PushManifestMismatch)
15733 );
15734 }
15735
15736 #[test]
15737 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
15738 let fixture = signed_remote_fixture();
15739 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
15740 let item = feed["entries"][0].to_string();
15741 let oversized_page = format!(
15742 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
15743 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
15744 .collect::<Vec<_>>()
15745 .join(",")
15746 );
15747 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
15748
15749 let oversized_identity = format!(
15750 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
15751 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
15752 .collect::<Vec<_>>()
15753 .join(",")
15754 );
15755 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
15756
15757 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
15758 let oversized_entry = format!(
15759 "{{\"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\"}}",
15760 "a".repeat(64),
15761 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
15762 .collect::<Vec<_>>()
15763 .join(",")
15764 );
15765 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
15766 }
15767
15768 #[test]
15769 fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
15770 let id = "01arz3ndektsv4rrffq69g5fav";
15771 let digest = "a".repeat(64);
15772 assert_eq!(
15773 V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
15774 V2BulkConfirmation {
15775 id: id.to_string(),
15776 digest,
15777 }
15778 );
15779 for invalid in [
15780 "",
15781 "01arz3ndektsv4rrffq69g5fav",
15782 "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15783 "01arz3ndektsv4rrffq69g5fav:ABCDEF",
15784 "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15785 ] {
15786 assert!(matches!(
15787 V2BulkConfirmation::parse(invalid),
15788 Err(LinkError::InvalidPack { .. })
15789 ));
15790 }
15791 }
15792
15793 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
15794 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15795 use std::net::TcpListener;
15796
15797 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15798 let url = format!("http://{}", listener.local_addr().unwrap());
15799 let handle = std::thread::spawn(move || {
15800 for (status, body) in responses {
15801 let (stream, _) = listener.accept().unwrap();
15802 let mut reader = BufReader::new(stream);
15803 let mut line = String::new();
15804 reader.read_line(&mut line).unwrap();
15805 let mut content_length = 0usize;
15806 loop {
15807 line.clear();
15808 reader.read_line(&mut line).unwrap();
15809 if line == "\r\n" || line == "\n" || line.is_empty() {
15810 break;
15811 }
15812 if let Some((name, value)) = line.split_once(':') {
15813 if name.eq_ignore_ascii_case("content-length") {
15814 content_length = value.trim().parse().unwrap();
15815 }
15816 }
15817 }
15818 let mut request_body = vec![0_u8; content_length];
15819 reader.read_exact(&mut request_body).unwrap();
15820 let response = format!(
15821 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15822 body.len()
15823 );
15824 reader.get_mut().write_all(response.as_bytes()).unwrap();
15825 }
15826 });
15827 (url, handle)
15828 }
15829
15830 fn routed_json_hub(
15831 requests: usize,
15832 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
15833 ) -> (String, std::thread::JoinHandle<()>) {
15834 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15835 use std::net::TcpListener;
15836
15837 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15838 let url = format!("http://{}", listener.local_addr().unwrap());
15839 let handle = std::thread::spawn(move || {
15840 for _ in 0..requests {
15841 let (stream, _) = listener.accept().unwrap();
15842 let mut reader = BufReader::new(stream);
15843 let mut line = String::new();
15844 reader.read_line(&mut line).unwrap();
15845 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
15846 let mut content_length = 0usize;
15847 loop {
15848 line.clear();
15849 reader.read_line(&mut line).unwrap();
15850 if line == "\r\n" || line == "\n" || line.is_empty() {
15851 break;
15852 }
15853 if let Some((name, value)) = line.split_once(':') {
15854 if name.eq_ignore_ascii_case("content-length") {
15855 content_length = value.trim().parse().unwrap();
15856 }
15857 }
15858 }
15859 let mut request_body = vec![0_u8; content_length];
15860 reader.read_exact(&mut request_body).unwrap();
15861 let (status, body) = respond(&path);
15862 let response = format!(
15863 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15864 body.len()
15865 );
15866 reader.get_mut().write_all(response.as_bytes()).unwrap();
15867 }
15868 });
15869 (url, handle)
15870 }
15871
15872 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
15873 HubConfig {
15874 hub,
15875 key: Some("test-key".to_string()),
15876 agent_key: None,
15877 brain_key: None,
15878 state_dir,
15879 store_selected: false,
15880 }
15881 }
15882
15883 #[cfg(any(unix, windows))]
15884 #[test]
15885 fn an_expired_asset_capability_is_refreshed_without_losing_cached_progress() {
15886 use std::sync::{Arc, Mutex};
15887
15888 let bytes = b"immutable asset bytes".to_vec();
15889 let sha256 = content_sha256(&bytes);
15890 let commit_hash = "c".repeat(64);
15891 let base_url = Arc::new(Mutex::new(String::new()));
15892 let server_base = Arc::clone(&base_url);
15893 let object_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15894 let server_attempt = Arc::clone(&object_attempt);
15895 let response_bytes = bytes.clone();
15896 let response_sha = sha256.clone();
15897 let response_commit = commit_hash.clone();
15898 let (hub, server) = routed_json_hub(4, move |path| {
15899 if path.contains("/v2/assets/downloads") {
15900 let attempt = server_attempt.load(std::sync::atomic::Ordering::SeqCst) + 1;
15901 let url = format!("{}/asset/{attempt}", server_base.lock().unwrap());
15902 return (
15903 200,
15904 json!({
15905 "v": 2,
15906 "commit": response_commit,
15907 "downloads": [{
15908 "path": "assets/proof.bin",
15909 "sha256": response_sha,
15910 "bytes": response_bytes.len(),
15911 "url": url,
15912 "method": "GET"
15913 }]
15914 })
15915 .to_string(),
15916 );
15917 }
15918 let attempt = server_attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15919 if attempt == 0 {
15920 (403, "{}".to_string())
15921 } else {
15922 (200, String::from_utf8(response_bytes.clone()).unwrap())
15923 }
15924 });
15925 *base_url.lock().unwrap() = hub.clone();
15926
15927 let temp = tempfile::tempdir().unwrap();
15928 let cache = temp.path().join("cache");
15929 std::fs::create_dir(&cache).unwrap();
15930 let cfg = test_hub_config(hub, temp.path().to_path_buf());
15931 let pointer = V2PointerBody {
15932 v: 2,
15933 brain: TEST_BRAIN_ID.to_string(),
15934 seq: 1,
15935 commit_hash,
15936 feed_hash: "f".repeat(64),
15937 content_root: Some("a".repeat(64)),
15938 asset_root: Some("b".repeat(64)),
15939 materializer: "m".repeat(64),
15940 signer_epoch: 1,
15941 control_revision: "d".repeat(64),
15942 backup_preparation: "ready".to_string(),
15943 prior_pointer_hash: None,
15944 signed_at: "2026-08-23T00:00:00Z".to_string(),
15945 };
15946 let path = "assets/proof.bin".to_string();
15947 let asset = V2BaselineAsset {
15948 blob_sha256: sha256.clone(),
15949 bytes: bytes.len() as u64,
15950 media_type: "application/octet-stream".to_string(),
15951 wrappers: Vec::new(),
15952 required: true,
15953 disposition: "hosted".to_string(),
15954 leaf_hash: "e".repeat(64),
15955 };
15956
15957 let staged = stage_v2_asset_download_window(
15958 &cfg,
15959 TEST_BRAIN_ID,
15960 &pointer,
15961 &cache,
15962 &[(&path, &asset)],
15963 )
15964 .expect("a fresh authority-checked capability recovers an expired one");
15965 assert_eq!(staged.len(), 1);
15966 assert_eq!(staged[0].path, path);
15967 assert_eq!(std::fs::read(cache.join(sha256)).unwrap(), bytes);
15968 assert_eq!(object_attempt.load(std::sync::atomic::Ordering::SeqCst), 2);
15969 server.join().unwrap();
15970 }
15971
15972 #[cfg(any(unix, windows))]
15973 #[test]
15974 fn asset_capability_windows_cannot_exceed_the_worker_bound() {
15975 let temp = tempfile::tempdir().unwrap();
15976 let cfg = test_hub_config("http://127.0.0.1:1".to_string(), temp.path().to_path_buf());
15977 let pointer = V2PointerBody {
15978 v: 2,
15979 brain: TEST_BRAIN_ID.to_string(),
15980 seq: 1,
15981 commit_hash: "c".repeat(64),
15982 feed_hash: "f".repeat(64),
15983 content_root: Some("a".repeat(64)),
15984 asset_root: Some("b".repeat(64)),
15985 materializer: "m".repeat(64),
15986 signer_epoch: 1,
15987 control_revision: "d".repeat(64),
15988 backup_preparation: "ready".to_string(),
15989 prior_pointer_hash: None,
15990 signed_at: "2026-08-23T00:00:00Z".to_string(),
15991 };
15992 let paths = (0..=V2_DOWNLOAD_CAPABILITY_FILES)
15993 .map(|index| format!("assets/{index}.bin"))
15994 .collect::<Vec<_>>();
15995 let assets = paths
15996 .iter()
15997 .map(|_| V2BaselineAsset {
15998 blob_sha256: "a".repeat(64),
15999 bytes: 1,
16000 media_type: "application/octet-stream".to_string(),
16001 wrappers: Vec::new(),
16002 required: true,
16003 disposition: "hosted".to_string(),
16004 leaf_hash: "b".repeat(64),
16005 })
16006 .collect::<Vec<_>>();
16007 let pending = paths.iter().zip(&assets).collect::<Vec<_>>();
16008
16009 let error =
16010 stage_v2_asset_download_window(&cfg, TEST_BRAIN_ID, &pointer, temp.path(), &pending)
16011 .expect_err("an oversized window must fail before any network request");
16012 assert!(matches!(error, LinkError::InvalidFeed { .. }));
16013 }
16014
16015 #[test]
16016 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
16017 use ring::signature::KeyPair as _;
16018
16019 let rng = ring::rand::SystemRandom::new();
16020 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16021 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16022 let (spki, multikey) = public_identity_for(&pair);
16023 let key = AgentSigningKey {
16024 pkcs8: pkcs8.as_ref().to_vec(),
16025 multikey,
16026 public_key_spki: spki,
16027 };
16028 let header = linkmd_sig_header(
16029 &key,
16030 "https://hub-a.example",
16031 "post",
16032 "/api/hub/brains/brain/push?mode=exact",
16033 Some("{\"ok\":true}"),
16034 )
16035 .unwrap();
16036 assert!(header.starts_with("LinkMD-Sig v2,"));
16037 let ts = header
16038 .split(",ts=")
16039 .nth(1)
16040 .unwrap()
16041 .split(',')
16042 .next()
16043 .unwrap();
16044 let signature = URL_SAFE_NO_PAD
16045 .decode(header.rsplit(",sig=").next().unwrap())
16046 .unwrap();
16047 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
16048 let accepted = format!(
16049 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
16050 );
16051 let replayed = format!(
16052 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
16053 );
16054 let public = pair.public_key().as_ref();
16055 assert!(UnparsedPublicKey::new(&ED25519, public)
16056 .verify(accepted.as_bytes(), &signature)
16057 .is_ok());
16058 assert!(
16059 UnparsedPublicKey::new(&ED25519, public)
16060 .verify(replayed.as_bytes(), &signature)
16061 .is_err(),
16062 "a proof captured at hub A must not authenticate at hub B"
16063 );
16064 }
16065
16066 #[test]
16067 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
16068 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16069 let card = json!({
16070 "id": other,
16071 "headSeq": 0,
16072 "identity": signed_remote_fixture().identity,
16073 })
16074 .to_string();
16075 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
16076 let state = tempfile::tempdir().unwrap();
16077 let cfg = test_hub_config(hub, state.path().to_path_buf());
16078 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16079 assert!(
16080 error.contains("differs from the explicitly requested"),
16081 "{error}"
16082 );
16083 server.join().unwrap();
16084 }
16085
16086 #[test]
16087 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
16088 let first = signed_remote_fixture().identity;
16089 let second = signed_remote_fixture().identity;
16090 let card = |identity: FeedIdentity| {
16091 json!({
16092 "id": TEST_BRAIN_ID,
16093 "headSeq": 0,
16094 "identity": identity,
16095 })
16096 .to_string()
16097 };
16098 let (hub, server) = scripted_json_hub(vec![
16099 (404, "{}".to_string()),
16100 (200, card(first)),
16101 (404, "{}".to_string()),
16102 (200, card(second)),
16103 ]);
16104 let state = tempfile::tempdir().unwrap();
16105 let cfg = test_hub_config(hub, state.path().to_path_buf());
16106 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16107 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16108 assert!(
16109 error.contains("pinned anchor") || error.contains("forked away"),
16110 "{error}"
16111 );
16112 server.join().unwrap();
16113 }
16114
16115 #[test]
16116 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
16117 let old = signed_remote_fixture();
16118 let new = signed_remote_fixture();
16119 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
16120 let unsigned = serde_json::to_string(&UnsignedRotation {
16121 v: 1,
16122 op: "rotate",
16123 brain: &old.key.multikey,
16124 public_key: &old.key.public_key_spki,
16125 new_brain: &new.key.multikey,
16126 new_public_key: &new.key.public_key_spki,
16127 prior_head_seq: 1,
16128 prior_feed_hash: Some(&"a".repeat(64)),
16129 ts: "2026-07-30T12:00:00.000Z".to_string(),
16130 })
16131 .unwrap();
16132 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
16133 let rotation = format!(
16134 "{},\"sig\":\"{}\"}}",
16135 &unsigned[..unsigned.len() - 1],
16136 signature
16137 );
16138 let identity = FeedIdentity {
16139 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
16140 public_key_spki: new.key.public_key_spki,
16141 previous: vec![PreviousIdentity {
16142 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
16143 public_key_spki: old.key.public_key_spki,
16144 }],
16145 rotations: vec![rotation],
16146 };
16147 let card = json!({
16148 "id": TEST_BRAIN_ID,
16149 "headSeq": 0,
16150 "feedHash": null,
16151 "identity": identity,
16152 })
16153 .to_string();
16154 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
16155 let state = tempfile::tempdir().unwrap();
16156 let cfg = test_hub_config(hub, state.path().to_path_buf());
16157 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16158 assert!(
16159 error.contains("rotation claims a feed boundary beyond the advertised head"),
16160 "{error}"
16161 );
16162 assert!(
16163 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
16164 "an inconsistent empty-head identity must not become the TOFU checkpoint"
16165 );
16166 server.join().unwrap();
16167 }
16168
16169 #[test]
16170 fn trust_checkpoint_rejects_a_later_fork() {
16171 let fixture = signed_remote_fixture();
16172 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
16173 fork["feedHash"] = Value::String("b".repeat(64));
16174 let (hub, server) = scripted_json_hub(vec![
16175 (404, "{}".to_string()),
16176 (200, fixture.card),
16177 (200, fixture.feed),
16178 (404, "{}".to_string()),
16179 (200, fork.to_string()),
16180 ]);
16181 let state = tempfile::tempdir().unwrap();
16182 let cfg = test_hub_config(hub, state.path().to_path_buf());
16183 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16184 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
16185 server.join().unwrap();
16186 }
16187
16188 #[test]
16189 fn alias_and_canonical_id_share_one_identity_checkpoint() {
16190 let trusted = signed_remote_fixture();
16191 let attacker = signed_remote_fixture();
16192 let (hub, server) = scripted_json_hub(vec![
16193 (404, "{}".to_string()),
16194 (200, trusted.card),
16195 (200, trusted.feed),
16196 (404, "{}".to_string()),
16197 (200, attacker.card),
16198 ]);
16199 let state = tempfile::tempdir().unwrap();
16200 let cfg = test_hub_config(hub, state.path().to_path_buf());
16201 assert!(head(&cfg, "trusted-slug").unwrap().verified);
16202 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16203 assert!(
16204 error.contains("equivocation")
16205 || error.contains("pinned")
16206 || error.contains("identity"),
16207 "{error}"
16208 );
16209 server.join().unwrap();
16210 }
16211
16212 #[test]
16213 fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
16214 let state = tempfile::tempdir().unwrap();
16215 let cfg = test_hub_config(
16216 "https://hub.example".to_string(),
16217 state.path().to_path_buf(),
16218 );
16219 let directory = open_trust_dir(&cfg).unwrap();
16220 let old = TEST_BRAIN_ID;
16221 let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16222 save_alias_in(
16223 &cfg,
16224 &directory,
16225 &AliasBinding {
16226 v: 1,
16227 origin: normalized_origin(&cfg.hub).unwrap(),
16228 requested: "company-brain".to_string(),
16229 brain: old.to_string(),
16230 home: Some("company-brain".to_string()),
16231 },
16232 )
16233 .unwrap();
16234
16235 let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
16236 assert!(matches!(
16237 error,
16238 LinkError::AliasRebindRequired {
16239 alias,
16240 from,
16241 to
16242 } if alias == "company-brain" && from == old && to == new
16243 ));
16244 let unchanged = load_alias_in(&cfg, &directory, "company-brain")
16245 .unwrap()
16246 .unwrap();
16247 assert_eq!(unchanged.brain, old);
16248 assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
16249 }
16250
16251 #[test]
16252 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
16253 let alpha = signed_remote_fixture();
16254 let beta = signed_remote_fixture();
16255 let alpha_card = alpha.card.clone();
16256 let alpha_feed = alpha.feed.clone();
16257 let beta_card = beta.card.clone();
16258 let beta_feed = beta.feed.clone();
16259 let (hub, server) = routed_json_hub(5, move |path| {
16260 if path.ends_with("/v2/head") {
16261 (404, "{}".to_string())
16262 } else if path.contains("/alpha/feed?") {
16263 (200, alpha_feed.clone())
16264 } else if path.contains("/beta/feed?") {
16265 (200, beta_feed.clone())
16266 } else if path.ends_with("/alpha") {
16267 (200, alpha_card.clone())
16268 } else if path.ends_with("/beta") {
16269 (200, beta_card.clone())
16270 } else {
16271 (500, r#"{"error":"unexpected path"}"#.to_string())
16272 }
16273 });
16274 let state = tempfile::tempdir().unwrap();
16275 let cfg = test_hub_config(hub, state.path().to_path_buf());
16276 let alpha_cfg = cfg.clone();
16277 let beta_cfg = cfg;
16278 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
16279 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
16280 let results = [first.join().unwrap(), second.join().unwrap()];
16281 assert_eq!(
16282 results.iter().filter(|result| result.is_ok()).count(),
16283 1,
16284 "only one alias identity may establish canonical TOFU: {results:?}"
16285 );
16286 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
16287 server.join().unwrap();
16288 }
16289
16290 #[cfg(unix)]
16291 #[test]
16292 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
16293 use std::os::unix::fs::symlink;
16294
16295 let fixture = signed_remote_fixture();
16296 let card = json!({
16297 "id": TEST_BRAIN_ID,
16298 "headSeq": 0,
16299 "feedHash": Value::Null,
16300 "identity": fixture.identity,
16301 })
16302 .to_string();
16303 let work = tempfile::tempdir().unwrap();
16304 let outside = tempfile::tempdir().unwrap();
16305 let state = work.path().join("state");
16306 let moved = work.path().join("state-held");
16307 let swap_state = state.clone();
16308 let swap_moved = moved.clone();
16309 let outside_path = outside.path().to_path_buf();
16310 let (hub, server) = routed_json_hub(1, move |_| {
16311 std::fs::rename(&swap_state, &swap_moved).unwrap();
16313 symlink(&outside_path, &swap_state).unwrap();
16314 (200, card.clone())
16315 });
16316 let cfg = test_hub_config(hub, state);
16317
16318 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
16319 assert_eq!(verified.head.seq, 0);
16320 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
16321 assert!(std::fs::read_dir(moved.join("trust"))
16322 .unwrap()
16323 .flatten()
16324 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
16325 server.join().unwrap();
16326 }
16327
16328 #[test]
16329 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
16330 let remote = signed_remote_fixture();
16331 let unrelated = signed_remote_fixture().key;
16332 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
16333 let state = tempfile::tempdir().unwrap();
16334 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
16335 cfg.brain_key = Some(unrelated);
16336 let error = sync_push(
16337 &cfg,
16338 TEST_BRAIN_ID,
16339 &[("DB.md".to_string(), "signed local content".to_string())],
16340 )
16341 .unwrap_err()
16342 .to_string();
16343 assert!(
16344 error.contains("not the verified current brain identity"),
16345 "{error}"
16346 );
16347 server.join().unwrap();
16348 }
16349
16350 #[test]
16351 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
16352 let remote = signed_remote_fixture();
16353 let new = signed_remote_fixture().key;
16354 let state = tempfile::tempdir().unwrap();
16355 let new_file = state.path().join("new.key");
16356 std::fs::write(
16357 &new_file,
16358 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
16359 )
16360 .unwrap();
16361 #[cfg(unix)]
16362 {
16363 use std::os::unix::fs::PermissionsExt as _;
16364 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
16365 }
16366 let forged = json!({
16367 "brain": TEST_BRAIN_ID,
16368 "identity": {
16369 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
16370 "publicKeySpki": new.public_key_spki,
16371 }
16372 })
16373 .to_string();
16374 let (hub, server) = scripted_json_hub(vec![
16375 (404, "{}".to_string()),
16376 (200, remote.card.clone()),
16377 (200, remote.feed.clone()),
16378 (200, forged),
16379 (200, remote.card),
16380 (200, remote.feed),
16381 ]);
16382 let cfg = test_hub_config(hub, state.path().to_path_buf());
16383 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
16384 .unwrap_err()
16385 .to_string();
16386 assert!(
16387 error.contains("without committing the verified new identity"),
16388 "{error}"
16389 );
16390 server.join().unwrap();
16391 }
16392
16393 #[test]
16394 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
16395 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16396 let raw = format!(
16397 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16398 );
16399 let pack = build_store_pack(&[
16400 (
16401 "DB.md".to_string(),
16402 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
16403 ),
16404 ("records/clients/truth.md".to_string(), raw.clone()),
16405 ])
16406 .unwrap();
16407 let by_id = resolve_from_verified_pack(
16408 "01j5qc3v9k4ym8rwbn2tqe6f7d",
16409 &AddressTarget::Id(record_id.to_string()),
16410 pack.clone(),
16411 )
16412 .unwrap();
16413 assert_eq!(by_id["document"]["summary"], "Signed truth");
16414 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
16415 assert_eq!(
16416 by_id["document"]["contentSha"],
16417 content_sha256(raw.as_bytes())
16418 );
16419
16420 let by_path = resolve_from_verified_pack(
16421 "01j5qc3v9k4ym8rwbn2tqe6f7d",
16422 &AddressTarget::Path("records/clients/truth.md".to_string()),
16423 pack,
16424 )
16425 .unwrap();
16426 assert_eq!(by_path["document"]["id"], record_id);
16427 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
16428
16429 let wrong_id = resolve_from_verified_record_bytes(
16430 TEST_BRAIN_ID,
16431 &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
16432 "records/clients/truth.md".to_string(),
16433 raw.as_bytes().to_vec(),
16434 )
16435 .unwrap_err()
16436 .to_string();
16437 assert!(wrong_id.contains("id differs"), "{wrong_id}");
16438
16439 let wrong_path = resolve_from_verified_record_bytes(
16440 TEST_BRAIN_ID,
16441 &AddressTarget::Path("records/clients/other.md".to_string()),
16442 "records/clients/truth.md".to_string(),
16443 raw.into_bytes(),
16444 )
16445 .unwrap_err()
16446 .to_string();
16447 assert!(wrong_path.contains("path differs"), "{wrong_path}");
16448 }
16449
16450 #[test]
16451 fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
16452 let path = "records/clients/truth.md";
16453 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16454 let raw = format!(
16455 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16456 );
16457 let sha256 = content_sha256(raw.as_bytes());
16458 let mut nonce = 0_u128;
16459 let tree = crate::linkmd_v2::build_content_tree(
16460 &[crate::linkmd_v2::ContentFile {
16461 path: path.to_string(),
16462 blob_hash: sha256.clone(),
16463 bytes: raw.len() as u64,
16464 }],
16465 None,
16466 &mut || {
16467 nonce += 1;
16468 format!("{nonce:032x}")
16469 },
16470 )
16471 .unwrap();
16472 let root = tree.root.clone().unwrap();
16473 let mut directory_root = root.clone();
16474 let mut proof = Vec::new();
16475 for component in path.split('/') {
16476 let inclusion =
16477 crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
16478 let child = match &inclusion {
16479 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
16480 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
16481 panic!("fixture path must have an inclusion proof")
16482 }
16483 };
16484 proof.push(json!({
16485 "directory_root": directory_root,
16486 "component": component,
16487 "proof": inclusion,
16488 }));
16489 directory_root = child;
16490 }
16491 let commit_hash = "c".repeat(64);
16492 let pointer = V2PointerBody {
16493 v: 2,
16494 brain: TEST_BRAIN_ID.to_string(),
16495 seq: 1,
16496 commit_hash: commit_hash.clone(),
16497 feed_hash: "f".repeat(64),
16498 content_root: Some(root.clone()),
16499 asset_root: None,
16500 materializer: "dbmd-projection-v1".to_string(),
16501 signer_epoch: 1,
16502 control_revision: "d".repeat(64),
16503 backup_preparation: "e".repeat(64),
16504 prior_pointer_hash: None,
16505 signed_at: "2026-08-21T12:00:00.000Z".to_string(),
16506 };
16507 let manifest = json!({
16508 "v": 2,
16509 "commit": commit_hash,
16510 "content_root": root,
16511 "files": [{
16512 "path": path,
16513 "sha256": sha256,
16514 "bytes": raw.len(),
16515 "proof": proof,
16516 }],
16517 "next_cursor": Value::Null,
16518 })
16519 .to_string();
16520
16521 let path_manifest = manifest.clone();
16522 let (hub, server) = routed_json_hub(1, move |request| {
16523 assert_eq!(
16524 request,
16525 format!(
16526 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
16527 "c".repeat(64)
16528 )
16529 );
16530 (200, path_manifest.clone())
16531 });
16532 let state = tempfile::tempdir().unwrap();
16533 let cfg = test_hub_config(hub, state.path().to_path_buf());
16534 let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
16535 .unwrap()
16536 .unwrap();
16537 assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
16538 assert!(by_path.proof.is_some());
16539 server.join().unwrap();
16540
16541 let (hub, server) = routed_json_hub(1, move |request| {
16542 assert_eq!(
16543 request,
16544 format!(
16545 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
16546 "c".repeat(64)
16547 )
16548 );
16549 (404, r#"{"error":"File not found"}"#.to_string())
16550 });
16551 let state = tempfile::tempdir().unwrap();
16552 let cfg = test_hub_config(hub, state.path().to_path_buf());
16553 assert!(
16554 v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
16555 .unwrap()
16556 .is_none()
16557 );
16558 server.join().unwrap();
16559
16560 let id_manifest = manifest;
16561 let (hub, server) = routed_json_hub(1, move |request| {
16562 assert_eq!(
16563 request,
16564 format!(
16565 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
16566 "c".repeat(64)
16567 )
16568 );
16569 (200, id_manifest.clone())
16570 });
16571 let state = tempfile::tempdir().unwrap();
16572 let cfg = test_hub_config(hub, state.path().to_path_buf());
16573 let (located_path, by_id) =
16574 v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
16575 assert_eq!(located_path, path);
16576 assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
16577 server.join().unwrap();
16578 }
16579
16580 #[test]
16581 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
16582 let unsorted = vec![
16583 ("records/a.md".to_string(), "alpha\n".to_string()),
16584 ("DB.md".to_string(), "# db\n".to_string()),
16585 ];
16586 let sorted = vec![
16587 ("DB.md".to_string(), "# db\n".to_string()),
16588 ("records/a.md".to_string(), "alpha\n".to_string()),
16589 ];
16590 let pack = build_store_pack(&unsorted).unwrap();
16591
16592 assert_eq!(pack.len(), 219);
16597 assert_eq!(
16598 content_sha256(&pack),
16599 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
16600 );
16601 assert_eq!(pack, build_store_pack(&sorted).unwrap());
16602 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
16603 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
16604 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
16605
16606 assert_eq!(
16607 parse_store_pack(pack).unwrap(),
16608 vec![
16609 ("DB.md".to_string(), b"# db\n".to_vec()),
16610 ("records/a.md".to_string(), b"alpha\n".to_vec()),
16611 ]
16612 );
16613 }
16614
16615 #[test]
16616 fn canonical_store_pack_validates_every_path_before_writing() {
16617 let duplicate = vec![
16618 ("DB.md".to_string(), "first".to_string()),
16619 ("DB.md".to_string(), "second".to_string()),
16620 ];
16621 assert!(build_store_pack(&duplicate)
16622 .unwrap_err()
16623 .to_string()
16624 .contains("duplicate path"));
16625 assert!(matches!(
16626 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
16627 Err(LinkError::UnsafePath { .. })
16628 ));
16629 }
16630
16631 #[test]
16632 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
16633 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16634 let mut bytes = vec![0_u8];
16637 let zip64_offset = bytes.len() as u64;
16638 bytes.extend_from_slice(b"PK\x06\x06");
16639 bytes.extend_from_slice(&44_u64.to_le_bytes());
16640 bytes.extend_from_slice(&[0_u8; 12]);
16641 bytes.extend_from_slice(&COUNT.to_le_bytes());
16642 bytes.extend_from_slice(&COUNT.to_le_bytes());
16643 bytes.extend_from_slice(&1_u64.to_le_bytes());
16644 bytes.extend_from_slice(&0_u64.to_le_bytes());
16645 bytes.extend_from_slice(b"PK\x06\x07");
16646 bytes.extend_from_slice(&0_u32.to_le_bytes());
16647 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16648 bytes.extend_from_slice(&1_u32.to_le_bytes());
16649 bytes.extend_from_slice(b"PK\x05\x06");
16650 bytes.extend_from_slice(&0_u16.to_le_bytes());
16651 bytes.extend_from_slice(&0_u16.to_le_bytes());
16652 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16653 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16654 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16655 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16656 bytes.extend_from_slice(&0_u16.to_le_bytes());
16657
16658 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16659 .unwrap_err()
16660 .to_string();
16661 assert!(error.contains("invalid file count"), "{error}");
16662 }
16663
16664 #[test]
16665 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
16666 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16667 let mut bytes = vec![0_u8];
16668 let zip64_offset = bytes.len() as u64;
16669 bytes.extend_from_slice(b"PK\x06\x06");
16670 bytes.extend_from_slice(&44_u64.to_le_bytes());
16671 bytes.extend_from_slice(&[0_u8; 12]);
16672 bytes.extend_from_slice(&COUNT.to_le_bytes());
16673 bytes.extend_from_slice(&COUNT.to_le_bytes());
16674 bytes.extend_from_slice(&1_u64.to_le_bytes());
16675 bytes.extend_from_slice(&0_u64.to_le_bytes());
16676 bytes.extend_from_slice(b"PK\x06\x07");
16677 bytes.extend_from_slice(&0_u32.to_le_bytes());
16678 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16679 bytes.extend_from_slice(&1_u32.to_le_bytes());
16680 bytes.extend_from_slice(b"PK\x05\x06");
16681 bytes.extend_from_slice(&0_u16.to_le_bytes());
16682 bytes.extend_from_slice(&0_u16.to_le_bytes());
16683 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16684 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16685 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16686 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16687 bytes.extend_from_slice(&0_u16.to_le_bytes());
16688 let fake_eocd = bytes.len() as u32;
16692 bytes.extend_from_slice(b"PK\x05\x06");
16693 bytes.extend_from_slice(&0_u16.to_le_bytes());
16694 bytes.extend_from_slice(&0_u16.to_le_bytes());
16695 bytes.extend_from_slice(&1_u16.to_le_bytes());
16696 bytes.extend_from_slice(&1_u16.to_le_bytes());
16697 bytes.extend_from_slice(&0_u32.to_le_bytes());
16698 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
16699 bytes.extend_from_slice(&0_u16.to_le_bytes());
16700
16701 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16702 .unwrap_err()
16703 .to_string();
16704 assert!(error.contains("central directory"), "{error}");
16705 }
16706
16707 #[test]
16708 fn strict_http_status_handling_rejects_redirects_without_panicking() {
16709 let error = ensure_ok(
16710 HubResponse {
16711 status: 302,
16712 body: Some(json!({"redirect": "/elsewhere"})),
16713 },
16714 "mutation",
16715 )
16716 .unwrap_err();
16717 assert!(matches!(error, LinkError::Http { status: 302, .. }));
16718
16719 let error = ensure_raw_ok(
16720 RawHubResponse {
16721 status: 302,
16722 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
16723 },
16724 "feed",
16725 )
16726 .unwrap_err();
16727 assert!(matches!(error, LinkError::Http { status: 302, .. }));
16728 }
16729
16730 #[cfg(unix)]
16731 #[test]
16732 fn collect_push_files_refuses_external_symlink_and_nested_store() {
16733 use std::os::unix::fs::symlink;
16734
16735 let root = tempfile::tempdir().unwrap();
16736 std::fs::write(
16737 root.path().join("DB.md"),
16738 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
16739 )
16740 .unwrap();
16741 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
16742
16743 let external = tempfile::tempdir().unwrap();
16744 let secret = external.path().join("secret.md");
16745 std::fs::write(&secret, "TOP SECRET").unwrap();
16746 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
16747
16748 let store = Store::open_strict(root.path()).unwrap();
16749 let err = collect_push_files(&store).unwrap_err().to_string();
16750 assert!(err.contains("cannot push"), "{err}");
16751 assert!(
16752 !err.contains("TOP SECRET"),
16753 "external bytes must never leak"
16754 );
16755
16756 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
16757 let nested = root.path().join("records/nested");
16758 std::fs::create_dir_all(&nested).unwrap();
16759 std::fs::write(
16760 nested.join("DB.md"),
16761 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
16762 )
16763 .unwrap();
16764 let err = collect_push_files(&store).unwrap_err().to_string();
16765 assert!(err.contains("nested db.md store"), "{err}");
16766 }
16767
16768 #[cfg(unix)]
16769 #[test]
16770 fn remote_push_uses_opened_root_after_path_replacement() {
16771 use std::os::unix::fs::symlink;
16772
16773 let sandbox = tempfile::tempdir().unwrap();
16774 let root = sandbox.path().join("store");
16775 std::fs::create_dir_all(root.join("records/notes")).unwrap();
16776 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16777 std::fs::write(
16778 root.join("records/notes/owned.md"),
16779 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
16780 )
16781 .unwrap();
16782 let store = Store::open_strict(&root).unwrap();
16783 let detached = sandbox.path().join("detached");
16784 std::fs::rename(&root, &detached).unwrap();
16785
16786 let replacement = sandbox.path().join("replacement");
16787 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
16788 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16789 std::fs::write(
16790 replacement.join("records/notes/secret.md"),
16791 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
16792 )
16793 .unwrap();
16794 symlink(&replacement, &root).unwrap();
16795
16796 let files = collect_push_files(&store).unwrap();
16797 let wire_text = files
16798 .iter()
16799 .map(|(path, content)| format!("{path}\n{content}"))
16800 .collect::<Vec<_>>()
16801 .join("\n");
16802 assert!(wire_text.contains("owned upload"));
16803 assert!(!wire_text.contains("replacement sentinel"));
16804 assert!(!wire_text.contains("records/notes/secret.md"));
16805
16806 let remote = signed_remote_fixture();
16807 let (hub, server) = scripted_json_hub(vec![
16808 (200, remote.card),
16809 (200, remote.feed),
16810 (200, json!({"ok": true}).to_string()),
16811 ]);
16812 let state = tempfile::tempdir().unwrap();
16813 let cfg = test_hub_config(hub, state.path().to_path_buf());
16814 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
16815 assert_eq!(pushed, json!({"ok": true}));
16816 server.join().unwrap();
16817 }
16818
16819 #[test]
16820 fn signed_feed_item_verifies_identity_hash_and_signature() {
16821 use ring::rand::SystemRandom;
16822 use ring::signature::{Ed25519KeyPair, KeyPair};
16823
16824 const PREFIX: &[u8] = &[
16825 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
16826 ];
16827 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
16828 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16829 let mut spki = PREFIX.to_vec();
16830 spki.extend_from_slice(pair.public_key().as_ref());
16831 let public_key = URL_SAFE_NO_PAD.encode(&spki);
16832 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
16833 let mut entry = FeedEntry {
16834 v: 1,
16835 seq: 1,
16836 ts: "2026-07-14T00:00:00.000Z".to_string(),
16837 brain: format!("ed25519:{fingerprint}"),
16838 public_key: public_key.clone(),
16839 kind: "push".to_string(),
16840 op: "snapshot".to_string(),
16841 pack_sha256: "a".repeat(64),
16842 files: vec![FeedFile {
16843 path: "DB.md".to_string(),
16844 sha256: "b".repeat(64),
16845 bytes: 3,
16846 }],
16847 removed: vec![],
16848 prev_entry_hash: None,
16849 sig: String::new(),
16850 };
16851 let unsigned = UnsignedFeedEntry {
16852 v: entry.v,
16853 seq: entry.seq,
16854 ts: &entry.ts,
16855 brain: &entry.brain,
16856 public_key: &entry.public_key,
16857 kind: &entry.kind,
16858 op: &entry.op,
16859 pack_sha256: &entry.pack_sha256,
16860 files: &entry.files,
16861 removed: &entry.removed,
16862 prev_entry_hash: &entry.prev_entry_hash,
16863 };
16864 entry.sig =
16865 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16866 let mut exact = serde_json::to_vec(&entry).unwrap();
16867 exact.push(b'\n');
16868 let item = FeedItem {
16869 hash: format!("{:x}", Sha256::digest(&exact)),
16870 entry,
16871 };
16872 let identity = FeedIdentity {
16873 fingerprint,
16874 public_key_spki: public_key,
16875 previous: Vec::new(),
16876 rotations: Vec::new(),
16877 };
16878 assert!(verify_feed_item(&item, &identity).is_ok());
16879 let mut tampered = item;
16880 tampered.entry.pack_sha256 = "c".repeat(64);
16881 assert!(verify_feed_item(&tampered, &identity).is_err());
16882 }
16883
16884 #[test]
16885 fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
16886 let rng = ring::rand::SystemRandom::new();
16887 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16888 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16889 let (spki, multikey) = public_identity_for(&pair);
16890 let identity = V2HeadIdentity {
16891 custody: "self".to_string(),
16892 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16893 public_key_spki: spki.clone(),
16894 previous: Vec::new(),
16895 rotations: Vec::new(),
16896 };
16897 let unsigned = json!({
16898 "actor_ref": "a".repeat(64),
16899 "asset_root": Value::Null,
16900 "brain": multikey,
16901 "changes_sha256": "b".repeat(64),
16902 "control_revision": "c".repeat(64),
16903 "materializer": "dbmd-projection-v1",
16904 "op": "changeset",
16905 "parent_asset_root": Value::Null,
16906 "parent_commit": Value::Null,
16907 "parent_root": Value::Null,
16908 "prev_entry_hash": Value::Null,
16909 "public_key": spki,
16910 "seq": 1,
16911 "signer_epoch": 1,
16912 "state_root": "d".repeat(64),
16913 "ts": "2026-08-19T12:00:00.000Z",
16914 "v": 2,
16915 "v1_bridge": {
16916 "feed_hash": "e".repeat(64),
16917 "head_seq": 7,
16918 "pack_sha256": "f".repeat(64),
16919 },
16920 });
16921 let sign_value = |value: Value| {
16922 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16923 let mut object = value.as_object().unwrap().clone();
16924 object.insert(
16925 "sig".to_string(),
16926 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16927 );
16928 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16929 };
16930 assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
16931
16932 let mut extra = unsigned.clone();
16933 extra
16934 .as_object_mut()
16935 .unwrap()
16936 .insert("future".to_string(), Value::Bool(true));
16937 assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
16938
16939 let mut missing = unsigned.clone();
16940 missing.as_object_mut().unwrap().remove("v1_bridge");
16941 assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
16942
16943 let mut invalid_bridge = unsigned;
16944 invalid_bridge.as_object_mut().unwrap().insert(
16945 "v1_bridge".to_string(),
16946 json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
16947 );
16948 assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
16949 }
16950
16951 #[test]
16952 fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
16953 let vector: Value = serde_json::from_str(include_str!(
16954 "../tests/vectors/linkmd-v2-commit-bridge.json"
16955 ))
16956 .unwrap();
16957 let identity_value = vector.get("identity").unwrap();
16958 let identity = V2HeadIdentity {
16959 custody: "self".to_string(),
16960 fingerprint: identity_value
16961 .get("fingerprint")
16962 .and_then(Value::as_str)
16963 .unwrap()
16964 .to_string(),
16965 public_key_spki: identity_value
16966 .get("public_key_spki")
16967 .and_then(Value::as_str)
16968 .unwrap()
16969 .to_string(),
16970 previous: Vec::new(),
16971 rotations: Vec::new(),
16972 };
16973 let private = URL_SAFE_NO_PAD
16974 .decode(
16975 identity_value
16976 .get("private_key_pkcs8")
16977 .and_then(Value::as_str)
16978 .unwrap(),
16979 )
16980 .unwrap();
16981 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
16982 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
16983 .unwrap();
16984 let base = vector.get("body").unwrap().as_object().unwrap();
16985
16986 for item in vector.get("valid").unwrap().as_array().unwrap() {
16987 let mut body = base.clone();
16988 body.insert(
16989 "v1_bridge".to_string(),
16990 item.get("v1_bridge").unwrap().clone(),
16991 );
16992 body.insert(
16993 "sig".to_string(),
16994 item.get("signature_base64url").unwrap().clone(),
16995 );
16996 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16997 assert!(verified_v2_commit_object(&signed, &identity).is_ok());
16998 assert_eq!(
16999 crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
17000 item.get("commit_hash").and_then(Value::as_str).unwrap()
17001 );
17002 assert_eq!(
17003 format!("{:x}", Sha256::digest(&signed)),
17004 item.get("feed_hash").and_then(Value::as_str).unwrap()
17005 );
17006 }
17007
17008 for item in vector.get("invalid").unwrap().as_array().unwrap() {
17009 let mut body = base.clone();
17010 if let Some(remove) = item.get("remove").and_then(Value::as_array) {
17011 for field in remove {
17012 body.remove(field.as_str().unwrap());
17013 }
17014 }
17015 if let Some(set) = item.get("set").and_then(Value::as_object) {
17016 for (field, value) in set {
17017 body.insert(field.clone(), value.clone());
17018 }
17019 }
17020 let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
17021 body.insert(
17022 "sig".to_string(),
17023 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17024 );
17025 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
17026 assert!(
17027 verified_v2_commit_object(&signed, &identity).is_err(),
17028 "accepted invalid shared vector {}",
17029 item.get("reason").and_then(Value::as_str).unwrap()
17030 );
17031 }
17032 }
17033
17034 #[test]
17035 fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
17036 let vector: Value = serde_json::from_str(include_str!(
17037 "../tests/vectors/linkmd-v2-changeset-withheld.json"
17038 ))
17039 .unwrap();
17040 assert_eq!(
17041 vector.get("profile").and_then(Value::as_str),
17042 Some("link.md-v2-changeset-withheld")
17043 );
17044 let canonical =
17045 crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
17046 let expected = STANDARD
17047 .decode(
17048 vector
17049 .get("canonical_base64")
17050 .and_then(Value::as_str)
17051 .unwrap(),
17052 )
17053 .unwrap();
17054 assert_eq!(canonical, expected);
17055 assert_eq!(
17056 crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
17057 vector.get("domain_hash").and_then(Value::as_str).unwrap()
17058 );
17059 }
17060
17061 #[test]
17062 fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
17063 let remote = signed_remote_fixture();
17064 let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
17065 let legacy_item = legacy.entries.first().unwrap();
17066 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
17067 let body = json!({
17068 "actor_ref": "a".repeat(64),
17069 "asset_root": Value::Null,
17070 "brain": remote.key.multikey,
17071 "changes_sha256": "b".repeat(64),
17072 "control_revision": "c".repeat(64),
17073 "materializer": "dbmd-projection-v1",
17074 "op": "changeset",
17075 "parent_asset_root": Value::Null,
17076 "parent_commit": Value::Null,
17077 "parent_root": Value::Null,
17078 "prev_entry_hash": Value::Null,
17079 "public_key": remote.key.public_key_spki,
17080 "seq": 1,
17081 "signer_epoch": 1,
17082 "state_root": "d".repeat(64),
17083 "ts": "2026-08-19T12:00:00.000Z",
17084 "v": 2,
17085 "v1_bridge": {
17086 "feed_hash": legacy_item.hash,
17087 "head_seq": legacy_item.entry.seq,
17088 "pack_sha256": legacy_item.entry.pack_sha256,
17089 },
17090 });
17091 let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
17092 let mut signed = body.as_object().unwrap().clone();
17093 signed.insert(
17094 "sig".to_string(),
17095 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17096 );
17097 let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
17098 let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
17099 let feed_hash = content_sha256(&raw);
17100 let pointer = V2PointerBody {
17101 v: 2,
17102 brain: TEST_BRAIN_ID.to_string(),
17103 seq: 1,
17104 commit_hash: commit_hash.clone(),
17105 feed_hash: feed_hash.clone(),
17106 content_root: Some("d".repeat(64)),
17107 asset_root: None,
17108 materializer: "dbmd-projection-v1".to_string(),
17109 signer_epoch: 1,
17110 control_revision: "c".repeat(64),
17111 backup_preparation: "e".repeat(64),
17112 prior_pointer_hash: None,
17113 signed_at: "2026-08-19T12:00:00.000Z".to_string(),
17114 };
17115 let v2_page = json!({
17116 "v": 2,
17117 "head_seq": 1,
17118 "head_commit_hash": commit_hash,
17119 "head_feed_hash": feed_hash,
17120 "entries": [{
17121 "seq": 1,
17122 "commit_hash": pointer.commit_hash,
17123 "feed_hash": pointer.feed_hash,
17124 "bytes_base64": STANDARD.encode(&raw),
17125 }],
17126 "next_after": 1,
17127 "complete": true,
17128 })
17129 .to_string();
17130 let identity = V2HeadIdentity {
17131 custody: "self".to_string(),
17132 fingerprint: remote.identity.fingerprint.clone(),
17133 public_key_spki: remote.identity.public_key_spki.clone(),
17134 previous: Vec::new(),
17135 rotations: Vec::new(),
17136 };
17137 let checkpoint = TrustState {
17138 v: 2,
17139 origin: "unused".to_string(),
17140 requested: TEST_BRAIN_ID.to_string(),
17141 brain: TEST_BRAIN_ID.to_string(),
17142 home: None,
17143 anchor: remote.key.multikey.clone(),
17144 current: remote.key.multikey,
17145 head_seq: legacy_item.entry.seq,
17146 feed_hash: Some(legacy_item.hash.clone()),
17147 rotations: Vec::new(),
17148 hub_signer: None,
17149 protocol_profile: None,
17150 };
17151 let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
17152 let state = tempfile::tempdir().unwrap();
17153 let cfg = test_hub_config(hub, state.path().to_path_buf());
17154 verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
17155 server.join().unwrap();
17156
17157 let mut wrong = checkpoint;
17158 wrong.feed_hash = Some("0".repeat(64));
17159 let (hub, server) = scripted_json_hub(vec![(
17160 200,
17161 json!({
17162 "v": 2,
17163 "head_seq": 1,
17164 "head_commit_hash": pointer.commit_hash,
17165 "head_feed_hash": pointer.feed_hash,
17166 "entries": [{
17167 "seq": 1,
17168 "commit_hash": pointer.commit_hash,
17169 "feed_hash": pointer.feed_hash,
17170 "bytes_base64": STANDARD.encode(&raw),
17171 }],
17172 "next_after": 1,
17173 "complete": true,
17174 })
17175 .to_string(),
17176 )]);
17177 let state = tempfile::tempdir().unwrap();
17178 let cfg = test_hub_config(hub, state.path().to_path_buf());
17179 assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
17180 server.join().unwrap();
17181 }
17182
17183 #[test]
17184 fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
17185 let rng = ring::rand::SystemRandom::new();
17186 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17187 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17188 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17189 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17190 let (old_spki, old_multikey) = public_identity_for(&old);
17191 let (new_spki, new_multikey) = public_identity_for(&new);
17192 let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
17193 v: 1,
17194 op: "rotate",
17195 brain: &old_multikey,
17196 public_key: &old_spki,
17197 new_brain: &new_multikey,
17198 new_public_key: &new_spki,
17199 prior_head_seq: 1,
17200 prior_feed_hash: Some(&"9".repeat(64)),
17201 ts: "2026-08-19T12:01:00.000Z".to_string(),
17202 })
17203 .unwrap();
17204 let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
17205 let rotation = format!(
17206 "{},\"sig\":\"{}\"}}",
17207 &rotation_unsigned[..rotation_unsigned.len() - 1],
17208 rotation_sig
17209 );
17210 let identity = V2HeadIdentity {
17211 custody: "self".to_string(),
17212 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17213 public_key_spki: new_spki.clone(),
17214 previous: vec![V2PreviousIdentity {
17215 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17216 public_key_spki: old_spki.clone(),
17217 }],
17218 rotations: vec![rotation],
17219 };
17220 let commit = |seq: u64,
17221 epoch: u64,
17222 multikey: &str,
17223 spki: &str,
17224 pair: &ring::signature::Ed25519KeyPair| {
17225 let value = json!({
17226 "actor_ref": "a".repeat(64),
17227 "asset_root": Value::Null,
17228 "brain": multikey,
17229 "changes_sha256": "b".repeat(64),
17230 "control_revision": "c".repeat(64),
17231 "materializer": "dbmd-projection-v1",
17232 "op": "changeset",
17233 "parent_asset_root": Value::Null,
17234 "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
17235 "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
17236 "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
17237 "public_key": spki,
17238 "seq": seq,
17239 "signer_epoch": epoch,
17240 "state_root": "1".repeat(64),
17241 "ts": "2026-08-19T12:00:00.000Z",
17242 "v": 2,
17243 "v1_bridge": Value::Null,
17244 });
17245 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
17246 let mut object = value.as_object().unwrap().clone();
17247 object.insert(
17248 "sig".to_string(),
17249 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17250 );
17251 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
17252 };
17253
17254 assert!(verified_v2_commit_object(
17255 &commit(1, 1, &old_multikey, &old_spki, &old),
17256 &identity,
17257 )
17258 .is_ok());
17259 assert!(verified_v2_commit_object(
17260 &commit(2, 2, &new_multikey, &new_spki, &new),
17261 &identity,
17262 )
17263 .is_ok());
17264 assert!(verified_v2_commit_object(
17265 &commit(2, 1, &old_multikey, &old_spki, &old),
17266 &identity,
17267 )
17268 .is_err());
17269 assert!(verified_v2_commit_object(
17270 &commit(1, 2, &new_multikey, &new_spki, &new),
17271 &identity,
17272 )
17273 .is_err());
17274 }
17275
17276 #[test]
17277 fn a_self_custody_entry_verifies_like_any_hub_entry() {
17278 let rng = ring::rand::SystemRandom::new();
17279 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17280 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17281 let (spki, multikey) = public_identity_for(&pair);
17282 let key = AgentSigningKey {
17283 pkcs8: pkcs8.as_ref().to_vec(),
17284 multikey: multikey.clone(),
17285 public_key_spki: spki.clone(),
17286 };
17287 let files = vec![WireFeedFile {
17288 path: "DB.md".to_string(),
17289 sha256: "a".repeat(64),
17290 bytes: 3,
17291 }];
17292 let raw = self_custody_entry(
17293 &key,
17294 1,
17295 "2026-07-23T12:00:00.000Z".to_string(),
17296 &"c".repeat(64),
17297 &files,
17298 None,
17299 )
17300 .unwrap();
17301 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
17305 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
17306 let item = FeedItem { hash, entry };
17307 let identity = FeedIdentity {
17308 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
17309 public_key_spki: spki,
17310 previous: Vec::new(),
17311 rotations: Vec::new(),
17312 };
17313 assert!(verify_feed_item(&item, &identity).is_ok());
17314 }
17315
17316 #[test]
17317 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
17318 let rng = ring::rand::SystemRandom::new();
17319 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17320 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17321 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17322 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17323 let (old_spki, old_multikey) = public_identity_for(&old);
17324 let (new_spki, new_multikey) = public_identity_for(&new);
17325 let unsigned = serde_json::to_string(&UnsignedRotation {
17326 v: 1,
17327 op: "rotate",
17328 brain: &old_multikey,
17329 public_key: &old_spki,
17330 new_brain: &new_multikey,
17331 new_public_key: &new_spki,
17332 prior_head_seq: 1,
17333 prior_feed_hash: Some(&"a".repeat(64)),
17334 ts: "2026-07-30T12:00:00.000Z".to_string(),
17335 })
17336 .unwrap();
17337 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
17338 let rotation = format!(
17339 "{},\"sig\":\"{}\"}}",
17340 &unsigned[..unsigned.len() - 1],
17341 signature
17342 );
17343 let identity = FeedIdentity {
17344 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17345 public_key_spki: new_spki,
17346 previous: vec![PreviousIdentity {
17347 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17348 public_key_spki: old_spki,
17349 }],
17350 rotations: vec![rotation],
17351 };
17352 let pin = TrustState {
17353 v: 2,
17354 origin: "https://hub.example".to_string(),
17355 requested: "brain".to_string(),
17356 brain: "brain".to_string(),
17357 home: None,
17358 anchor: old_multikey.clone(),
17359 current: old_multikey.clone(),
17360 head_seq: 1,
17361 feed_hash: Some("a".repeat(64)),
17362 rotations: Vec::new(),
17363 hub_signer: None,
17364 protocol_profile: None,
17365 };
17366 assert_eq!(
17367 verify_identity_chain(&identity, Some(&pin)).unwrap(),
17368 old_multikey
17369 );
17370 let mut accepted = pin.clone();
17371 accepted.current = new_multikey.clone();
17372 accepted.rotations = identity.rotations.clone();
17373 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
17374 v: 1,
17375 op: "rotate",
17376 brain: &old_multikey,
17377 public_key: &identity.previous[0].public_key_spki,
17378 new_brain: &new_multikey,
17379 new_public_key: &identity.public_key_spki,
17380 prior_head_seq: 1,
17381 prior_feed_hash: Some(&"a".repeat(64)),
17382 ts: "2026-07-30T12:00:01.000Z".to_string(),
17383 })
17384 .unwrap();
17385 let alternate_signature =
17386 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
17387 let mut rewritten = identity.clone();
17388 rewritten.rotations[0] = format!(
17389 "{},\"sig\":\"{}\"}}",
17390 &alternate_unsigned[..alternate_unsigned.len() - 1],
17391 alternate_signature
17392 );
17393 assert!(
17394 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
17395 "an alternate valid statement must not rewrite accepted history"
17396 );
17397
17398 let mut stale_entry = FeedEntry {
17399 v: 1,
17400 seq: 2,
17401 ts: "2026-07-30T12:01:00.000Z".to_string(),
17402 brain: pin.current.clone(),
17403 public_key: identity.previous[0].public_key_spki.clone(),
17404 kind: "push".to_string(),
17405 op: "snapshot".to_string(),
17406 pack_sha256: "b".repeat(64),
17407 files: Vec::new(),
17408 removed: Vec::new(),
17409 prev_entry_hash: pin.feed_hash.clone(),
17410 sig: String::new(),
17411 };
17412 let stale_unsigned = UnsignedFeedEntry {
17413 v: stale_entry.v,
17414 seq: stale_entry.seq,
17415 ts: &stale_entry.ts,
17416 brain: &stale_entry.brain,
17417 public_key: &stale_entry.public_key,
17418 kind: &stale_entry.kind,
17419 op: &stale_entry.op,
17420 pack_sha256: &stale_entry.pack_sha256,
17421 files: &stale_entry.files,
17422 removed: &stale_entry.removed,
17423 prev_entry_hash: &stale_entry.prev_entry_hash,
17424 };
17425 stale_entry.sig = URL_SAFE_NO_PAD.encode(
17426 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
17427 .as_ref(),
17428 );
17429 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
17430 stale_exact.push(b'\n');
17431 let stale_item = FeedItem {
17432 hash: content_sha256(&stale_exact),
17433 entry: stale_entry,
17434 };
17435 assert!(
17436 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
17437 .is_err(),
17438 "a key retired before the checkpoint must never append after it"
17439 );
17440 assert!(
17441 verify_feed_item(&stale_item, &identity).is_err(),
17442 "an old key must never append after its signed rotation boundary"
17443 );
17444
17445 let mut missing = identity.clone();
17446 missing.rotations.clear();
17447 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
17448
17449 let mut tampered = identity;
17450 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
17451 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
17452 }
17453
17454 #[cfg(unix)]
17455 #[test]
17456 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
17457 use std::os::unix::fs::symlink;
17458
17459 let dir = tempfile::tempdir().unwrap();
17460 let target = dir.path().join("valuable.txt");
17461 let planted = dir.path().join("agent.key");
17462 std::fs::write(&target, "do not overwrite").unwrap();
17463 symlink(&target, &planted).unwrap();
17464
17465 assert!(matches!(
17466 generate_agent_key(&planted),
17467 Err(LinkError::BadAgentKey { .. })
17468 ));
17469 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
17470 }
17471
17472 #[cfg(unix)]
17473 #[test]
17474 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
17475 use std::os::unix::fs::symlink;
17476
17477 let root = tempfile::tempdir().unwrap();
17478 let outside = tempfile::tempdir().unwrap();
17479 symlink(outside.path(), root.path().join("redirect")).unwrap();
17480
17481 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
17482 assert!(!outside.path().join("agent.key").exists());
17483 }
17484
17485 #[test]
17488 fn address_bare_brain_with_and_without_sigil() {
17489 for raw in ["@acme-ops", "acme-ops"] {
17490 let a = Address::parse(raw).expect(raw);
17491 assert_eq!(a.brain, "acme-ops");
17492 assert_eq!(a.target, None);
17493 }
17494 }
17495
17496 #[test]
17497 fn address_ulid_target_parses_as_id() {
17498 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
17499 assert_eq!(a.brain, "acme");
17500 assert_eq!(
17501 a.target,
17502 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
17503 );
17504 }
17505
17506 #[test]
17507 fn address_md_path_target_parses_as_path() {
17508 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
17509 assert_eq!(
17510 a.target,
17511 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
17512 );
17513 }
17514
17515 #[test]
17516 fn address_rejects_malformed_forms() {
17517 for raw in [
17518 "",
17519 "@",
17520 "@/x",
17521 "@acme/",
17522 "@acme/../etc/passwd",
17523 "@acme/records/.hidden.md",
17524 "@ACME", "@acme/notes/x.txt", "@a b", ] {
17528 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
17529 }
17530 }
17531
17532 #[test]
17535 fn safe_paths_accept_store_shapes_and_reject_escapes() {
17536 for ok in [
17537 "DB.md",
17538 "assets.jsonl",
17539 "records/clients/lumio.md",
17540 "sources/emails/2026/07/x.md",
17541 ] {
17542 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
17543 }
17544 for bad in [
17545 "",
17546 "/etc/passwd",
17547 "../up.md",
17548 "records/../../up.md",
17549 "records//x.md",
17550 ".dbmd/config",
17551 "records/.hidden/x.md",
17552 "records/a b.md",
17553 "records\\win.md",
17554 ] {
17555 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
17556 }
17557 }
17558
17559 #[cfg(unix)]
17560 #[test]
17561 fn opened_destination_capability_survives_an_ancestor_path_swap() {
17562 use std::os::unix::fs::symlink;
17563
17564 let work = tempfile::tempdir().unwrap();
17565 let outside = tempfile::tempdir().unwrap();
17566 let original = work.path().join("destination");
17567 let moved = work.path().join("destination-moved");
17568 let directory = open_or_create_dir_nofollow(&original).unwrap();
17569
17570 std::fs::rename(&original, &moved).unwrap();
17571 symlink(outside.path(), &original).unwrap();
17572 write_pull_entries_beneath_dir(
17573 &directory,
17574 &[("records/note.md".to_string(), b"held inode".to_vec())],
17575 )
17576 .unwrap();
17577
17578 assert_eq!(
17579 std::fs::read(moved.join("records/note.md")).unwrap(),
17580 b"held inode"
17581 );
17582 assert!(!outside.path().join("records/note.md").exists());
17583 }
17584
17585 #[test]
17589 fn hub_config_flag_beats_file_and_requires_some_source() {
17590 let dir = tempfile::tempdir().unwrap();
17591 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
17592 std::fs::write(
17593 dir.path().join(CONFIG_REL_PATH),
17594 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
17595 )
17596 .unwrap();
17597
17598 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
17599 assert_eq!(from_flag.hub, "https://flag.example.com");
17600
17601 let from_file = hub_config(None, dir.path()).unwrap();
17602 assert_eq!(from_file.hub, "https://file.example.com");
17603
17604 let none = hub_config(None, tempfile::tempdir().unwrap().path());
17605 assert!(matches!(none, Err(LinkError::NoHub)));
17606 }
17607
17608 #[test]
17609 fn https_guard_allows_loopback_only_for_plain_http() {
17610 assert!(assert_safe_hub("https://hub.example.com").is_ok());
17611 assert!(assert_safe_hub("http://localhost:3000").is_ok());
17612 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
17613 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
17614 assert!(matches!(
17615 assert_safe_hub("http://hub.example.com"),
17616 Err(LinkError::UnsafeHub { .. })
17617 ));
17618 assert!(matches!(
17619 assert_safe_hub("hub.example.com"),
17620 Err(LinkError::UnsafeHub { .. })
17621 ));
17622 assert!(matches!(
17623 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
17624 Err(LinkError::UnsafeHub { .. })
17625 ));
17626 assert!(matches!(
17627 assert_safe_hub("https://hub.example.com@attacker.example"),
17628 Err(LinkError::UnsafeHub { .. })
17629 ));
17630 assert!(matches!(
17631 assert_safe_hub("https://hub.example.com/base"),
17632 Err(LinkError::UnsafeHub { .. })
17633 ));
17634 }
17635
17636 #[test]
17637 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
17638 for blocked in [
17639 "127.0.0.1",
17640 "10.0.0.1",
17641 "100.64.0.1",
17642 "169.254.169.254",
17643 "172.16.0.1",
17644 "192.168.0.1",
17645 "192.88.99.1",
17646 "198.18.0.1",
17647 "203.0.113.1",
17648 "::1",
17649 "fe80::1",
17650 "fd00::1",
17651 "2001:db8::1",
17652 "2001:1::1",
17653 "2002:7f00:1::",
17654 "3fff::1",
17655 ] {
17656 assert!(
17657 !is_public_registry_ip(blocked.parse().unwrap()),
17658 "must block {blocked}"
17659 );
17660 }
17661 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
17662 assert!(is_public_registry_ip(
17663 "2606:4700:4700::1111".parse().unwrap()
17664 ));
17665 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
17666 }
17667
17668 #[test]
17669 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
17670 use ureq::Resolver as _;
17671
17672 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
17673 let resolver = PinnedRegistryResolver {
17674 netloc: "home.example:443".to_string(),
17675 addresses: vec![pinned],
17676 };
17677 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
17678 assert!(resolver.resolve("127.0.0.1:443").is_err());
17679 assert_eq!(
17680 resolver.resolve("home.example:443").unwrap(),
17681 vec![pinned],
17682 "subsequent connects reuse the validated answer instead of DNS"
17683 );
17684 }
17685
17686 #[test]
17687 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
17688 let cfg = HubConfig {
17689 hub: "https://hub.example".to_string(),
17690 key: None,
17691 agent_key: None,
17692 brain_key: None,
17693 state_dir: tempfile::tempdir().unwrap().keep(),
17694 store_selected: false,
17695 };
17696 assert!(
17697 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
17698 "a production hub must not turn its presigned URL into an SSRF primitive"
17699 );
17700
17701 let store_selected = HubConfig {
17702 hub: "https://127.0.0.1".to_string(),
17703 store_selected: true,
17704 ..cfg
17705 };
17706 assert!(
17707 hub_agent(&store_selected).is_err(),
17708 "bytes in a cloned store must not select a private-network hub"
17709 );
17710 }
17711
17712 #[test]
17713 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
17714 assert_eq!(
17715 one_past_bounded_limit(MAX_PACK_BYTES),
17716 Some(MAX_PACK_BYTES + 1),
17717 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
17718 );
17719 assert_eq!(
17720 presigned_download_read_limit(),
17721 MAX_PACK_BYTES + 1,
17722 "the presigned reader is capped by the client constant, not a hub response"
17723 );
17724 assert_eq!(
17725 one_past_bounded_limit(u64::MAX),
17726 None,
17727 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
17728 );
17729 }
17730
17731 #[test]
17732 fn https_guard_matches_the_scheme_case_insensitively() {
17733 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
17736 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
17737 assert!(matches!(
17739 assert_safe_hub("HTTP://hub.example.com"),
17740 Err(LinkError::UnsafeHub { .. })
17741 ));
17742 }
17743
17744 #[test]
17745 fn clean_key_refuses_paste_artifacts_without_echoing() {
17746 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
17747 for bad in ["vc account", "vc\naccount", "ключ", ""] {
17748 let err = clean_key(bad).unwrap_err();
17749 assert!(matches!(err, LinkError::BadKey));
17750 assert!(
17751 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
17752 "error must not echo the key"
17753 );
17754 }
17755 }
17756
17757 fn dead_hub() -> HubConfig {
17763 HubConfig {
17764 hub: "http://127.0.0.1:9".to_string(),
17765 key: Some("k".to_string()),
17766 agent_key: None,
17767 brain_key: None,
17768 state_dir: PathBuf::from("."),
17769 store_selected: false,
17770 }
17771 }
17772
17773 #[test]
17774 fn request_retries_a_connection_failure_before_sending() {
17775 use std::io::{Read as _, Write as _};
17776 use std::net::TcpListener;
17777 use std::thread;
17778 use std::time::Duration;
17779
17780 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
17781 let address = probe.local_addr().unwrap();
17782 drop(probe);
17783 let server = thread::spawn(move || {
17784 thread::sleep(Duration::from_millis(40));
17785 let listener = TcpListener::bind(address).unwrap();
17786 let (mut stream, _) = listener.accept().unwrap();
17787 let mut request_bytes = [0_u8; 1024];
17788 let _ = stream.read(&mut request_bytes).unwrap();
17789 stream
17790 .write_all(
17791 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17792 )
17793 .unwrap();
17794 });
17795 let cfg = HubConfig {
17796 hub: format!("http://{address}"),
17797 key: None,
17798 agent_key: None,
17799 brain_key: None,
17800 state_dir: tempfile::tempdir().unwrap().keep(),
17801 store_selected: false,
17802 };
17803
17804 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
17805 assert_eq!(response.status, 200);
17806 assert_eq!(response.body, Some(json!({ "ok": true })));
17807 server.join().unwrap();
17808 }
17809
17810 #[test]
17811 fn a_commit_goes_back_for_a_receipt_it_lost() {
17812 use std::io::{Read as _, Write as _};
17813 use std::net::TcpListener;
17814 use std::thread;
17815
17816 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17822 let address = listener.local_addr().unwrap();
17823 let server = thread::spawn(move || {
17824 let (mut first, _) = listener.accept().unwrap();
17826 let mut bytes = [0_u8; 4096];
17827 let _ = first.read(&mut bytes).unwrap();
17828 first
17829 .write_all(
17830 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
17831 )
17832 .unwrap();
17833 drop(first);
17834 let (mut second, _) = listener.accept().unwrap();
17836 let _ = second.read(&mut bytes).unwrap();
17837 second
17838 .write_all(
17839 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\"}",
17840 )
17841 .unwrap();
17842 });
17843 let cfg = HubConfig {
17844 hub: format!("http://{address}"),
17845 key: Some("k".to_string()),
17846 agent_key: None,
17847 brain_key: None,
17848 state_dir: tempfile::tempdir().unwrap().keep(),
17849 store_selected: false,
17850 };
17851
17852 let response = request_patient(
17853 &cfg,
17854 "POST",
17855 "/api/hub/brains/b/v2/commits",
17856 Some(&json!({ "mutation_id": "dbmd-1" })),
17857 Auth::Required,
17858 )
17859 .expect("the receipt is collected on the second ask");
17860 assert_eq!(response.status, 200);
17861 assert_eq!(
17862 response
17863 .body
17864 .as_ref()
17865 .and_then(|value| value.get("outcome"))
17866 .and_then(Value::as_str),
17867 Some("converged"),
17868 "an already-applied mutation answers with its receipt"
17869 );
17870 server.join().unwrap();
17871 }
17872
17873 #[test]
17874 fn a_mutation_body_that_dies_mid_stream_is_a_transport_failure() {
17875 use std::io::{Read as _, Write as _};
17876 use std::net::TcpListener;
17877 use std::thread;
17878
17879 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17885 let address = listener.local_addr().unwrap();
17886 let server = thread::spawn(move || {
17887 let (mut stream, _) = listener.accept().unwrap();
17888 let mut request_bytes = [0_u8; 1024];
17889 let _ = stream.read(&mut request_bytes).unwrap();
17890 stream
17892 .write_all(
17893 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17894 )
17895 .unwrap();
17896 });
17897 let cfg = HubConfig {
17898 hub: format!("http://{address}"),
17899 key: None,
17900 agent_key: None,
17901 brain_key: None,
17902 state_dir: tempfile::tempdir().unwrap().keep(),
17903 store_selected: false,
17904 };
17905
17906 let error = request(&cfg, "POST", "/truncated", None, Auth::None)
17907 .expect_err("a truncated body must not read as success");
17908 match error {
17909 LinkError::Transport { hub, .. } => {
17910 assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
17911 }
17912 other => panic!("expected a transport failure, got {other:?}"),
17913 }
17914 server.join().unwrap();
17915 }
17916
17917 #[test]
17918 fn a_safe_get_retries_when_its_body_dies_mid_stream() {
17919 use std::io::{Read as _, Write as _};
17920 use std::net::{TcpListener, TcpStream};
17921 use std::thread;
17922
17923 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17924 let address = listener.local_addr().unwrap();
17925 let server = thread::spawn(move || {
17926 let read_request = |stream: &mut TcpStream| {
17927 let mut request = Vec::new();
17928 let mut bytes = [0_u8; 1024];
17929 loop {
17930 let read = stream.read(&mut bytes).unwrap();
17931 if read == 0 {
17932 break;
17933 }
17934 request.extend_from_slice(&bytes[..read]);
17935 let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17936 else {
17937 continue;
17938 };
17939 let headers = String::from_utf8_lossy(&request[..header_end]);
17940 let content_length = headers
17941 .lines()
17942 .find_map(|line| {
17943 let (name, value) = line.split_once(':')?;
17944 name.eq_ignore_ascii_case("content-length")
17945 .then(|| value.trim().parse::<usize>().ok())
17946 .flatten()
17947 })
17948 .unwrap_or(0);
17949 if request.len() >= header_end + 4 + content_length {
17950 break;
17951 }
17952 }
17953 };
17954 let (mut first, _) = listener.accept().unwrap();
17955 read_request(&mut first);
17956 first
17957 .write_all(
17958 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17959 )
17960 .unwrap();
17961 drop(first);
17962
17963 let (mut second, _) = listener.accept().unwrap();
17964 read_request(&mut second);
17965 second
17966 .write_all(
17967 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17968 )
17969 .unwrap();
17970 });
17971 let cfg = HubConfig {
17972 hub: format!("http://{address}"),
17973 key: None,
17974 agent_key: None,
17975 brain_key: None,
17976 state_dir: tempfile::tempdir().unwrap().keep(),
17977 store_selected: false,
17978 };
17979
17980 let response = request(&cfg, "GET", "/retry-body", None, Auth::None)
17981 .expect("a safe read retries the interrupted body");
17982 assert_eq!(response.status, 200);
17983 assert_eq!(response.body, Some(json!({ "ok": true })));
17984 server.join().unwrap();
17985 }
17986
17987 #[test]
17988 fn an_explicit_read_only_post_retries_when_its_body_dies_mid_stream() {
17989 use std::io::{Read as _, Write as _};
17990 use std::net::{TcpListener, TcpStream};
17991 use std::thread;
17992
17993 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17994 let address = listener.local_addr().unwrap();
17995 let server = thread::spawn(move || {
17996 let read_request = |stream: &mut TcpStream| {
17997 let mut request = Vec::new();
17998 let mut bytes = [0_u8; 1024];
17999 loop {
18000 let read = stream.read(&mut bytes).unwrap();
18001 if read == 0 {
18002 break;
18003 }
18004 request.extend_from_slice(&bytes[..read]);
18005 let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
18006 else {
18007 continue;
18008 };
18009 let headers = String::from_utf8_lossy(&request[..header_end]);
18010 let content_length = headers
18011 .lines()
18012 .find_map(|line| {
18013 let (name, value) = line.split_once(':')?;
18014 name.eq_ignore_ascii_case("content-length")
18015 .then(|| value.trim().parse::<usize>().ok())
18016 .flatten()
18017 })
18018 .unwrap_or(0);
18019 if request.len() >= header_end + 4 + content_length {
18020 break;
18021 }
18022 }
18023 };
18024 let (mut first, _) = listener.accept().unwrap();
18025 read_request(&mut first);
18026 first
18027 .write_all(
18028 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18029 )
18030 .unwrap();
18031 drop(first);
18032
18033 let (mut second, _) = listener.accept().unwrap();
18034 read_request(&mut second);
18035 second
18036 .write_all(
18037 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18038 )
18039 .unwrap();
18040 });
18041 let cfg = HubConfig {
18042 hub: format!("http://{address}"),
18043 key: None,
18044 agent_key: None,
18045 brain_key: None,
18046 state_dir: tempfile::tempdir().unwrap().keep(),
18047 store_selected: false,
18048 };
18049
18050 let response = request_raw_retryable_read(
18051 &cfg,
18052 "POST",
18053 "/v2/stream",
18054 Some(&json!({ "files": ["proof"] })),
18055 Auth::None,
18056 1_024,
18057 )
18058 .expect("an explicitly safe POST retries the interrupted body");
18059 assert_eq!(response.status, 200);
18060 assert_eq!(
18061 serde_json::from_slice::<Value>(&response.body).unwrap(),
18062 json!({ "ok": true })
18063 );
18064 server.join().unwrap();
18065 }
18066
18067 #[test]
18068 fn object_store_transport_errors_never_render_presigned_urls() {
18069 use std::net::TcpListener;
18070
18071 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18072 let address = listener.local_addr().unwrap();
18073 drop(listener);
18074 let signature = "do-not-render-this-presigned-signature";
18075 let raw =
18076 format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
18077 let error = ureq::get(&raw)
18078 .timeout(std::time::Duration::from_millis(250))
18079 .call()
18080 .expect_err("the closed local port must fail");
18081 let ureq::Error::Transport(transport) = error else {
18082 panic!("expected a transport failure");
18083 };
18084
18085 let rendered = object_store_transport_error(transport).to_string();
18086 assert!(rendered.contains("the object store"));
18087 assert!(rendered.contains("network error"));
18088 assert!(!rendered.contains(&raw));
18089 assert!(!rendered.contains(signature));
18090 assert!(!rendered.contains("X-Amz-"));
18091 }
18092
18093 #[test]
18094 fn endpoint_cap_refuses_a_body_before_json_parsing() {
18095 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
18096 let cfg = HubConfig {
18097 hub,
18098 key: None,
18099 agent_key: None,
18100 brain_key: None,
18101 state_dir: tempfile::tempdir().unwrap().keep(),
18102 store_selected: false,
18103 };
18104
18105 assert!(matches!(
18106 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
18107 Err(LinkError::ResponseTooLarge { .. })
18108 ));
18109 server.join().unwrap();
18110 }
18111
18112 #[test]
18113 fn overall_deadline_stops_a_dribbled_response_body() {
18114 use std::io::{Read as _, Write as _};
18115 use std::net::TcpListener;
18116 use std::time::{Duration, Instant};
18117
18118 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18119 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
18120 let server = std::thread::spawn(move || {
18121 let (mut stream, _) = listener.accept().unwrap();
18122 let mut request = [0_u8; 1024];
18123 let _ = stream.read(&mut request);
18124 stream
18125 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
18126 .unwrap();
18127 for byte in [b'x'; 32] {
18128 if stream.write_all(&[byte]).is_err() {
18129 break;
18130 }
18131 std::thread::sleep(Duration::from_millis(40));
18132 }
18133 });
18134 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
18135 let started = Instant::now();
18136 let response = http.get(&url).call().unwrap();
18137 let mut body = Vec::new();
18138 let error = response
18139 .into_reader()
18140 .read_to_end(&mut body)
18141 .expect_err("per-read progress must not reset the overall deadline");
18142 assert!(
18143 started.elapsed() < Duration::from_millis(700),
18144 "dribbled body exceeded the wall-clock budget: {error}"
18145 );
18146 server.join().unwrap();
18147 }
18148
18149 #[test]
18150 fn overall_deadline_stops_a_stalled_upload() {
18151 use std::net::TcpListener;
18152 use std::time::{Duration, Instant};
18153
18154 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18155 let url = format!("http://{}/upload", listener.local_addr().unwrap());
18156 let server = std::thread::spawn(move || {
18157 let (_stream, _) = listener.accept().unwrap();
18158 std::thread::sleep(Duration::from_millis(600));
18161 });
18162 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
18163 let body = vec![0x5a; 32 * 1024 * 1024];
18164 let started = Instant::now();
18165 let error = http
18166 .put(&url)
18167 .send_bytes(&body)
18168 .expect_err("stalled request-body writes must time out");
18169 assert!(
18170 started.elapsed() < Duration::from_millis(700),
18171 "stalled upload exceeded the wall-clock budget: {error}"
18172 );
18173 server.join().unwrap();
18174 }
18175
18176 #[test]
18177 fn presigned_source_retries_share_one_upload_deadline() {
18178 use std::net::TcpListener;
18179 use std::time::{Duration, Instant};
18180
18181 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18182 let address = listener.local_addr().unwrap();
18183 let signature = "do-not-render-this-stalled-upload-signature";
18184 let url = format!(
18185 "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
18186 );
18187 let server = std::thread::spawn(move || {
18188 let (_stream, _) = listener.accept().unwrap();
18189 std::thread::sleep(Duration::from_millis(600));
18193 });
18194
18195 let directory = tempfile::tempdir().unwrap();
18196 std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
18197 std::fs::create_dir(directory.path().join("records")).unwrap();
18198 let relative = "records/stalled.bin";
18199 let bytes = vec![0x5a; 32 * 1024 * 1024];
18200 std::fs::write(directory.path().join(relative), &bytes).unwrap();
18201 let store = Store::open_strict(directory.path()).unwrap();
18202 let cfg = HubConfig {
18203 hub: format!("http://{address}"),
18204 key: None,
18205 agent_key: None,
18206 brain_key: None,
18207 state_dir: tempfile::tempdir().unwrap().keep(),
18208 store_selected: false,
18209 };
18210 let source = V2UploadSource {
18211 path: relative.to_string(),
18212 bytes: bytes.len() as u64,
18213 };
18214
18215 let started = Instant::now();
18216 let error = put_presigned_source_with_budget(
18217 &cfg,
18218 &url,
18219 &json!({ "content-length": source.bytes.to_string() }),
18220 &store,
18221 &source,
18222 None,
18223 Duration::from_millis(150),
18224 )
18225 .expect_err("a black-holed upload must leave at its shared deadline");
18226 assert!(
18227 started.elapsed() < Duration::from_millis(700),
18228 "presigned retries exceeded their shared budget: {error}"
18229 );
18230 let rendered = error.to_string();
18231 assert!(rendered.contains("the object store"));
18232 assert!(!rendered.contains(&url));
18233 assert!(!rendered.contains(signature));
18234 server.join().unwrap();
18235 }
18236
18237 #[test]
18238 fn verb_entry_gates_accept_the_hub_ref_shapes() {
18239 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
18240 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
18241 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
18242 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
18243 }
18244 }
18245
18246 #[test]
18247 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
18248 let cfg = dead_hub();
18249 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
18250 assert!(
18251 matches!(
18252 sync_pull(&cfg, bad, None),
18253 Err(LinkError::BadAddress { .. })
18254 ),
18255 "sync_pull must refuse {bad:?}"
18256 );
18257 assert!(
18258 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
18259 "sync_push must refuse {bad:?}"
18260 );
18261 assert!(
18262 matches!(
18263 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
18264 Err(LinkError::BadAddress { .. })
18265 ),
18266 "grant_issue must refuse {bad:?}"
18267 );
18268 assert!(
18269 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
18270 "grant_list must refuse {bad:?}"
18271 );
18272 assert!(
18273 matches!(
18274 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
18275 Err(LinkError::BadAddress { .. })
18276 ),
18277 "grant_revoke must refuse brain {bad:?}"
18278 );
18279 assert!(
18280 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
18281 "head must refuse {bad:?}"
18282 );
18283 }
18284 }
18285
18286 #[test]
18287 fn grant_revoke_refuses_url_reshaping_grant_ids() {
18288 let cfg = dead_hub();
18289 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
18290 assert!(
18291 matches!(
18292 grant_revoke(&cfg, "acme", bad),
18293 Err(LinkError::BadGrantId { .. })
18294 ),
18295 "grant_revoke must refuse grant id {bad:?}"
18296 );
18297 }
18298 }
18299
18300 #[test]
18301 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
18302 let cfg = dead_hub();
18303 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
18304 assert!(
18305 matches!(
18306 propose(&cfg, bad, "intake", "hi"),
18307 Err(LinkError::BadAddress { .. })
18308 ),
18309 "propose must refuse handle {bad:?}"
18310 );
18311 }
18312 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
18313 assert!(matches!(
18314 propose(&cfg, "acme-site", "intake", &oversize),
18315 Err(LinkError::ProposeTooLarge { .. })
18316 ));
18317 assert!(matches!(
18320 propose(&cfg, "acme-site", "intake", "hi"),
18321 Err(LinkError::Transport { .. })
18322 ));
18323 }
18324
18325 #[test]
18326 fn resolve_refuses_a_hand_built_unsafe_address() {
18327 let cfg = dead_hub();
18328 for brain in ["../up", "a/b", "a?x", "a#f"] {
18329 let addr = Address {
18330 brain: brain.to_string(),
18331 target: None,
18332 };
18333 assert!(
18334 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18335 "resolve must refuse brain {brain:?}"
18336 );
18337 }
18338 for target in [
18339 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
18340 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
18342 AddressTarget::Path("records/x.md#frag".to_string()),
18343 ] {
18344 let addr = Address {
18345 brain: "acme".to_string(),
18346 target: Some(target.clone()),
18347 };
18348 assert!(
18349 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18350 "resolve must refuse target {target:?}"
18351 );
18352 }
18353 }
18354
18355 #[test]
18356 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
18357 let mut local = std::collections::BTreeMap::new();
18358 local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
18359 local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
18360 let mut remote = std::collections::BTreeMap::new();
18361 remote.insert(
18362 "records/a.md".to_string(),
18363 V2BaselineFile {
18364 sha256: "c".repeat(64),
18365 bytes: 1,
18366 proof: None,
18367 },
18368 );
18369 remote.insert(
18370 "records/b.md".to_string(),
18371 V2BaselineFile {
18372 sha256: "b".repeat(64),
18373 bytes: 1,
18374 proof: None,
18375 },
18376 );
18377 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18378 }
18379
18380 #[test]
18381 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
18382 let local = std::collections::BTreeMap::new();
18383 let mut remote = std::collections::BTreeMap::new();
18384 remote.insert(
18385 "private/local.md".to_string(),
18386 V2BaselineFile {
18387 sha256: "d".repeat(64),
18388 bytes: 1,
18389 proof: None,
18390 },
18391 );
18392 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
18393 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18394 }
18395
18396 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
18397 V2VerifiedHead {
18398 requested: TEST_BRAIN_ID.to_string(),
18399 brain_id: TEST_BRAIN_ID.to_string(),
18400 view_kind: "scoped".to_string(),
18401 view_revision: revision.to_string(),
18402 control_revision: revision.to_string(),
18403 identity: V2HeadIdentity {
18404 custody: "hub".to_string(),
18405 fingerprint: "test".to_string(),
18406 public_key_spki: "test".to_string(),
18407 previous: Vec::new(),
18408 rotations: Vec::new(),
18409 },
18410 pointer: None,
18411 trust: TrustState {
18412 v: 2,
18413 origin: "https://hub.example".to_string(),
18414 requested: TEST_BRAIN_ID.to_string(),
18415 brain: TEST_BRAIN_ID.to_string(),
18416 home: None,
18417 anchor: "ed25519:test".to_string(),
18418 current: "ed25519:test".to_string(),
18419 head_seq: 0,
18420 feed_hash: None,
18421 rotations: Vec::new(),
18422 hub_signer: None,
18423 protocol_profile: Some("link-v2".to_string()),
18424 },
18425 alias: None,
18426 }
18427 }
18428
18429 #[test]
18430 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
18431 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
18432 assert!(accepted_as_v2(&trust));
18433
18434 trust.protocol_profile = None;
18435 trust.hub_signer = Some("ed25519:hub".to_string());
18436 assert!(accepted_as_v2(&trust));
18437
18438 trust.hub_signer = None;
18439 assert!(!accepted_as_v2(&trust));
18440 }
18441
18442 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
18443 V2SyncBaseline {
18444 v: 2,
18445 origin: "https://hub.example".to_string(),
18446 brain: TEST_BRAIN_ID.to_string(),
18447 checkout_id: Some("c".repeat(64)),
18448 head_seq: Some(0),
18449 commit_hash: None,
18450 content_root: None,
18451 asset_root: None,
18452 assets: std::collections::BTreeMap::new(),
18453 view_kind: Some("scoped".to_string()),
18454 view_revision: Some(revision.to_string()),
18455 control_revision: Some(revision.to_string()),
18456 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
18457 files: std::collections::BTreeMap::new(),
18458 local_policy_digest: None,
18459 local_eligibility: std::collections::BTreeMap::new(),
18460 remote_copy_remains: std::collections::BTreeMap::new(),
18461 }
18462 }
18463
18464 #[test]
18465 fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
18466 let cfg = test_hub_config(
18467 "https://hub.example".to_string(),
18468 tempfile::tempdir().unwrap().keep(),
18469 );
18470 let mut baseline = scoped_test_baseline(&"a".repeat(64));
18471 baseline.assets.insert(
18472 "assets/archive.bin".to_string(),
18473 V2BaselineAsset {
18474 blob_sha256: "b".repeat(64),
18475 bytes: MAX_STORE_BYTES + 1,
18476 media_type: "application/octet-stream".to_string(),
18477 wrappers: vec!["records/archive.md".to_string()],
18478 required: true,
18479 disposition: "hosted".to_string(),
18480 leaf_hash: "c".repeat(64),
18481 },
18482 );
18483
18484 let accepted = serde_json::to_vec(&baseline).unwrap();
18485 assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
18486
18487 baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
18488 let refused = serde_json::to_vec(&baseline).unwrap();
18489 assert!(matches!(
18490 parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
18491 Err(LinkError::InvalidFeed { .. })
18492 ));
18493 }
18494
18495 #[test]
18496 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
18497 let directory = tempfile::tempdir().unwrap();
18498 std::fs::write(
18499 directory.path().join("DB.md"),
18500 scoped_projection_bytes(TEST_BRAIN_ID),
18501 )
18502 .unwrap();
18503 let store = Store::open_strict(directory.path()).unwrap();
18504 let head = scoped_test_head(&"a".repeat(64));
18505 let baseline = scoped_test_baseline(&"a".repeat(64));
18506 let mut view = v2_local_files(&store).unwrap();
18507 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
18508 assert!(!view.riding.contains_key("DB.md"));
18509 assert!(!view.eligibility.contains_key("DB.md"));
18510 }
18511
18512 #[test]
18513 fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
18514 let directory = tempfile::tempdir().unwrap();
18515 std::fs::write(
18516 directory.path().join("DB.md"),
18517 scoped_projection_bytes(TEST_BRAIN_ID),
18518 )
18519 .unwrap();
18520 let store = Store::open_strict(directory.path()).unwrap();
18521 let head = scoped_test_head(&"a".repeat(64));
18522 let baseline = scoped_test_baseline(&"a".repeat(64));
18523
18524 let mut carried = v2_local_files(&store).unwrap();
18525 remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
18526 let handed_off =
18527 local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
18528 assert!(!handed_off.riding.contains_key("DB.md"));
18529
18530 let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
18531 assert!(!freshly_scanned.riding.contains_key("DB.md"));
18532
18533 std::fs::write(
18534 directory.path().join("DB.md"),
18535 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18536 )
18537 .unwrap();
18538 let tampered = Store::open_strict(directory.path()).unwrap();
18539 assert!(matches!(
18540 local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
18541 Err(LinkError::ScopedProjectionModified)
18542 ));
18543 }
18544
18545 #[test]
18546 fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
18547 let directory = tempfile::tempdir().unwrap();
18548 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18549 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18550 std::fs::write(
18551 directory.path().join("DB.md"),
18552 b"---\nname: Kept home test\n---\n",
18553 )
18554 .unwrap();
18555 std::fs::write(
18556 directory.path().join("records/notes/a.md"),
18557 b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
18558 )
18559 .unwrap();
18560 std::fs::write(
18561 directory.path().join("sources/private/secret.md"),
18562 b"---\ntype: note\n---\nlocal only\n",
18563 )
18564 .unwrap();
18565 std::fs::write(
18566 directory.path().join("sources/private/unlinked.md"),
18567 b"---\ntype: note\n---\nnot disclosed\n",
18568 )
18569 .unwrap();
18570 std::fs::write(
18571 directory.path().join(".sevralocal"),
18572 b"sources/private/**\n",
18573 )
18574 .unwrap();
18575
18576 let store = Store::open_strict(directory.path()).unwrap();
18577 let view = v2_local_files(&store).unwrap();
18578 assert!(!view.riding.contains_key("sources/private/secret.md"));
18579 assert_eq!(
18580 view.withheld_links,
18581 vec![V2WithheldLink {
18582 source: "records/notes/a.md".to_string(),
18583 target: "sources/private/secret.md".to_string(),
18584 }]
18585 );
18586 }
18587
18588 #[test]
18589 fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
18590 let directory = tempfile::tempdir().unwrap();
18595 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18596 std::fs::write(
18597 directory.path().join("DB.md"),
18598 b"---\nname: Restored export\n---\n",
18599 )
18600 .unwrap();
18601 std::fs::write(
18602 directory.path().join("records/notes/a.md"),
18603 b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
18604 )
18605 .unwrap();
18606 std::fs::write(
18607 directory.path().join(".sevralocal"),
18608 b"sources/private/**\n",
18609 )
18610 .unwrap();
18611
18612 let store = Store::open_strict(directory.path()).unwrap();
18613 let view = v2_local_files(&store).unwrap();
18614 assert_eq!(
18615 view.withheld_links,
18616 vec![V2WithheldLink {
18617 source: "records/notes/a.md".to_string(),
18618 target: "sources/private/absent.md".to_string(),
18619 }]
18620 );
18621 std::fs::write(
18623 directory.path().join("records/notes/b.md"),
18624 b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
18625 )
18626 .unwrap();
18627 let store = Store::open_strict(directory.path()).unwrap();
18628 let view = v2_local_files(&store).unwrap();
18629 assert!(
18630 !view
18631 .withheld_links
18632 .iter()
18633 .any(|link| link.target == "records/notes/nowhere.md"),
18634 "an unclaimed dangling target must not be declared withheld"
18635 );
18636 }
18637
18638 #[test]
18639 fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
18640 let directory = tempfile::tempdir().unwrap();
18641 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18642 std::fs::write(
18643 directory.path().join("DB.md"),
18644 b"---\nname: Withdrawal test\n---\n",
18645 )
18646 .unwrap();
18647 let source = b"---\ntype: note\n---\nlocal evidence\n";
18648 std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
18649 std::fs::write(
18650 directory.path().join(".sevralocal"),
18651 b"sources/private/**\n",
18652 )
18653 .unwrap();
18654 let store = Store::open_strict(directory.path()).unwrap();
18655 let view = v2_local_files(&store).unwrap();
18656 let mut remote = std::collections::BTreeMap::new();
18657 remote.insert(
18658 "sources/private/evidence.md".to_string(),
18659 V2BaselineFile {
18660 sha256: content_sha256(source),
18661 bytes: source.len() as u64,
18662 proof: None,
18663 },
18664 );
18665 assert_eq!(
18666 v2_content_withdrawal_operation(
18667 &store,
18668 &view,
18669 &remote,
18670 "sources/private/evidence.md",
18671 "approved retention change",
18672 )
18673 .unwrap(),
18674 json!({
18675 "op": "withdraw_from_hosting",
18676 "path": "sources/private/evidence.md",
18677 "expected": { "kind": "blob", "hash": content_sha256(source) },
18678 "reason": "approved retention change",
18679 })
18680 );
18681
18682 std::fs::write(
18683 directory.path().join("sources/private/evidence.md"),
18684 b"changed after review",
18685 )
18686 .unwrap();
18687 assert!(matches!(
18688 v2_content_withdrawal_operation(
18689 &store,
18690 &view,
18691 &remote,
18692 "sources/private/evidence.md",
18693 "approved retention change",
18694 ),
18695 Err(LinkError::InvalidPack { .. })
18696 ));
18697 }
18698
18699 #[test]
18700 fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
18701 let directory = tempfile::tempdir().unwrap();
18702 std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
18703 std::fs::write(
18704 directory.path().join("DB.md"),
18705 b"---\nname: Asset withdrawal test\n---\n",
18706 )
18707 .unwrap();
18708 let bytes = b"private binary";
18709 std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
18710 std::fs::write(
18711 directory.path().join(".sevralocal"),
18712 b"sources/files/private.pdf\n",
18713 )
18714 .unwrap();
18715 let store = Store::open_strict(directory.path()).unwrap();
18716 let view = v2_local_files(&store).unwrap();
18717 let local = crate::AssetRecord {
18718 path: "sources/files/private.pdf".to_string(),
18719 sha256: content_sha256(bytes),
18720 bytes: bytes.len() as u64,
18721 media_type: "application/pdf".to_string(),
18722 wrappers: vec![
18723 "sources/files/private.md".to_string(),
18724 "sources/redacted/private.md".to_string(),
18725 ],
18726 required: false,
18727 };
18728 let current = V2BaselineAsset {
18729 blob_sha256: local.sha256.clone(),
18730 bytes: local.bytes,
18731 media_type: local.media_type.clone(),
18732 wrappers: vec!["sources/files/private.md".to_string()],
18733 required: true,
18734 disposition: "hosted".to_string(),
18735 leaf_hash: "d".repeat(64),
18736 };
18737 assert_eq!(
18738 v2_asset_withdrawal_operation(
18739 &store,
18740 &view,
18741 &local.path,
18742 &local,
18743 ¤t,
18744 "approved retention change",
18745 )
18746 .unwrap(),
18747 json!({
18748 "op": "asset_withdraw",
18749 "path": local.path,
18750 "expected": { "kind": "asset", "hash": "d".repeat(64) },
18751 "asset": {
18752 "blob_sha256": local.sha256.clone(),
18753 "bytes": local.bytes,
18754 "media_type": local.media_type.clone(),
18755 "wrappers": local.wrappers.clone(),
18756 "required": false,
18757 "disposition": "withheld",
18758 },
18759 "reason": "approved retention change",
18760 })
18761 );
18762
18763 let mut mismatched = current.clone();
18764 mismatched.blob_sha256 = "f".repeat(64);
18765 assert!(matches!(
18766 v2_asset_withdrawal_operation(
18767 &store,
18768 &view,
18769 &local.path,
18770 &local,
18771 &mismatched,
18772 "approved retention change",
18773 ),
18774 Err(LinkError::InvalidPack { .. })
18775 ));
18776 }
18777
18778 #[test]
18779 fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
18780 let first = v2_checkout_id(None).unwrap();
18781 assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
18782 assert_ne!(first, v2_checkout_id(None).unwrap());
18783 assert!(is_sha256(&first));
18784 }
18785
18786 #[test]
18787 fn scoped_projection_edit_and_scope_transition_fail_closed() {
18788 let directory = tempfile::tempdir().unwrap();
18789 std::fs::write(
18790 directory.path().join("DB.md"),
18791 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
18792 )
18793 .unwrap();
18794 let store = Store::open_strict(directory.path()).unwrap();
18795 let head = scoped_test_head(&"a".repeat(64));
18796 let baseline = scoped_test_baseline(&"a".repeat(64));
18797 let mut view = v2_local_files(&store).unwrap();
18798 assert!(matches!(
18799 remove_scoped_projection(&head, Some(&baseline), &mut view),
18800 Err(LinkError::ScopedProjectionModified)
18801 ));
18802
18803 let changed = scoped_test_head(&"b".repeat(64));
18804 assert!(matches!(
18805 ensure_v2_view_compatible(&changed, Some(&baseline)),
18806 Err(LinkError::ScopedViewChanged)
18807 ));
18808
18809 let mut same_view_new_control = head.clone();
18810 same_view_new_control.control_revision = "c".repeat(64);
18811 assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
18812 assert!(!same_v2_head(&head, &same_view_new_control));
18813 }
18814
18815 #[test]
18816 fn verified_baseline_reuse_requires_exact_content_assets_view_and_authority() {
18817 let mut head = scoped_test_head(&"a".repeat(64));
18818 head.control_revision = "b".repeat(64);
18819 head.pointer = Some(V2PointerBody {
18820 v: 2,
18821 brain: TEST_BRAIN_ID.to_string(),
18822 seq: 7,
18823 commit_hash: "c".repeat(64),
18824 feed_hash: "d".repeat(64),
18825 content_root: Some("e".repeat(64)),
18826 asset_root: Some("f".repeat(64)),
18827 materializer: "dbmd-projection-v1".to_string(),
18828 signer_epoch: 1,
18829 control_revision: head.control_revision.clone(),
18830 backup_preparation: "0".repeat(64),
18831 prior_pointer_hash: Some("1".repeat(64)),
18832 signed_at: "2026-08-24T00:00:00.000Z".to_string(),
18833 });
18834 let mut baseline = scoped_test_baseline(&head.view_revision);
18835 baseline.head_seq = Some(7);
18836 baseline.commit_hash = Some("c".repeat(64));
18837 baseline.content_root = Some("e".repeat(64));
18838 baseline.asset_root = Some("f".repeat(64));
18839 baseline.control_revision = Some(head.control_revision.clone());
18840 assert!(v2_baseline_matches_head(&head, &baseline));
18841
18842 let mut changed = baseline.clone();
18843 changed.head_seq = Some(8);
18844 assert!(!v2_baseline_matches_head(&head, &changed));
18845 let mut changed = baseline.clone();
18846 changed.commit_hash = Some("2".repeat(64));
18847 assert!(!v2_baseline_matches_head(&head, &changed));
18848 let mut changed = baseline.clone();
18849 changed.content_root = Some("3".repeat(64));
18850 assert!(!v2_baseline_matches_head(&head, &changed));
18851 let mut changed = baseline.clone();
18852 changed.asset_root = Some("4".repeat(64));
18853 assert!(!v2_baseline_matches_head(&head, &changed));
18854 let mut changed = baseline.clone();
18855 changed.view_revision = Some("5".repeat(64));
18856 assert!(!v2_baseline_matches_head(&head, &changed));
18857 let mut changed = baseline.clone();
18858 changed.control_revision = Some("6".repeat(64));
18859 assert!(!v2_baseline_matches_head(&head, &changed));
18860
18861 let mut changed_head = head.clone();
18862 changed_head.view_kind = "full".to_string();
18863 assert!(!v2_baseline_matches_head(&changed_head, &baseline));
18864 }
18865
18866 #[test]
18867 fn legacy_baseline_without_authority_revision_refreshes_before_reuse() {
18868 let sandbox = tempfile::tempdir().unwrap();
18869 let cfg = test_hub_config(
18870 "https://hub.example".to_string(),
18871 sandbox.path().to_path_buf(),
18872 );
18873 let head = scoped_test_head(&"a".repeat(64));
18874 let baseline = scoped_test_baseline(&head.view_revision);
18875 let mut encoded = serde_json::to_value(&baseline).unwrap();
18876 encoded.as_object_mut().unwrap().remove("control_revision");
18877 let parsed =
18878 parse_v2_baseline(&cfg, TEST_BRAIN_ID, &serde_json::to_vec(&encoded).unwrap()).unwrap();
18879 assert!(parsed.control_revision.is_none());
18880 assert!(!v2_baseline_matches_head(&head, &parsed));
18881 }
18882
18883 #[test]
18884 fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
18885 let scoped = scoped_test_head(&"a".repeat(64));
18886 let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
18887 assert!(matches!(
18888 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
18889 Err(LinkError::ScopedProjectionModified)
18890 ));
18891
18892 let mut full = scoped.clone();
18893 full.view_kind = "full".to_string();
18894 let mut full_baseline = scoped_baseline.clone();
18895 full_baseline.view_kind = Some("full".to_string());
18896 full_baseline.projection_sha256 = None;
18897 assert!(matches!(
18898 ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
18899 Err(LinkError::InvalidPack { .. })
18900 ));
18901
18902 assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
18903 assert!(
18904 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
18905 );
18906 }
18907
18908 #[test]
18909 fn scoped_view_metadata_is_explicitly_non_authoritative() {
18910 let head = scoped_test_head(&"a".repeat(64));
18911 let value: Value =
18912 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
18913 assert_eq!(value["kind"], "link.md-scoped-view");
18914 assert_eq!(value["authoritative"], false);
18915 assert_eq!(value["visible_files"], 7);
18916 assert_eq!(value["brain"], TEST_BRAIN_ID);
18917 }
18918
18919 #[test]
18920 fn local_scoped_marker_requires_the_exact_generated_projection() {
18921 let directory = tempfile::tempdir().unwrap();
18922 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
18923 std::fs::write(
18924 directory.path().join("DB.md"),
18925 scoped_projection_bytes(TEST_BRAIN_ID),
18926 )
18927 .unwrap();
18928 let head = scoped_test_head(&"a".repeat(64));
18929 std::fs::write(
18930 directory.path().join(".dbmd/view.json"),
18931 scoped_view_metadata(&head, 0).unwrap(),
18932 )
18933 .unwrap();
18934 let store = Store::open_strict(directory.path()).unwrap();
18935 assert!(has_verified_local_scoped_view(&store));
18936
18937 std::fs::write(
18938 directory.path().join("DB.md"),
18939 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18940 )
18941 .unwrap();
18942 let altered = Store::open_strict(directory.path()).unwrap();
18943 assert!(!has_verified_local_scoped_view(&altered));
18944 }
18945
18946 fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
18947 use ring::signature::KeyPair as _;
18948
18949 let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
18950 let rng = ring::rand::SystemRandom::new();
18951 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18952 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18953 let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
18954 let public_key = URL_SAFE_NO_PAD.encode(&public_der);
18955 let fingerprint = format!("{:x}", Sha256::digest(&public_der));
18956 let blob = b"new";
18957 let blob_hash = content_sha256(blob);
18958 let changes = json!({
18959 "mutation_id": "sync:proposal-fixture",
18960 "operations": [{
18961 "blob": blob_hash,
18962 "bytes": blob.len(),
18963 "expected": null,
18964 "op": "put",
18965 "path": "records/new.md",
18966 }],
18967 "reason": "fixture",
18968 "v": 2,
18969 });
18970 let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
18971 let changes_base64 = STANDARD.encode(&changes_bytes);
18972 let descriptor = json!({
18973 "base": null,
18974 "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
18975 "changes_base64": changes_base64,
18976 "rebase": "strict",
18977 "v": 2,
18978 });
18979 let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
18980 let payload_hash = "b".repeat(64);
18981 let submitted_at = "2026-08-19T12:00:00.000Z";
18982 let claim = json!({
18983 "actor_root": {
18984 "actor_class": "foreign_key",
18985 "credential": "ed25519:fixture",
18986 "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
18987 "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
18988 "principal": "key:fixture",
18989 "role": null,
18990 },
18991 "brain": TEST_BRAIN_ID,
18992 "clear_sha256": clear_hash,
18993 "control_revision": "c".repeat(64),
18994 "mutation_id": "sync:proposal-fixture",
18995 "payload_sha256": payload_hash,
18996 "proposal_id": proposal_id,
18997 "submitted_at": submitted_at,
18998 "v": 2,
18999 });
19000 let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
19001 let envelope = json!({
19002 "claim": claim,
19003 "fingerprint": fingerprint,
19004 "public_key": public_key,
19005 "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
19006 });
19007 let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
19008 let submission_hash =
19009 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
19010 let mut head = scoped_test_head(&"c".repeat(64));
19011 head.view_kind = "full".to_string();
19012 head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
19013 let value = json!({
19014 "proposal": {
19015 "base": null,
19016 "blobs": [{
19017 "bytes": blob.len(),
19018 "endpoint": format!(
19019 "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
19020 ),
19021 "sha256": blob_hash,
19022 }],
19023 "changes_base64": changes_base64,
19024 "clear_sha256": clear_hash,
19025 "expires_at": "2026-08-26T12:00:00.000Z",
19026 "id": proposal_id,
19027 "payload_sha256": payload_hash,
19028 "proposer": { "class": "foreign_key" },
19029 "rebase": "strict",
19030 "state": "pending",
19031 "submission_claim_base64": STANDARD.encode(envelope_bytes),
19032 "submission_claim_sha256": submission_hash,
19033 "submitted_at": submitted_at,
19034 },
19035 "v": 2,
19036 });
19037 (head, proposal_id, value)
19038 }
19039
19040 #[test]
19041 fn v2_proposal_verifier_accepts_exact_signed_payload() {
19042 let (head, proposal_id, value) = signed_proposal_fixture();
19043 let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
19044 assert_eq!(verified.blobs.len(), 1);
19045 assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
19046 }
19047
19048 #[test]
19049 fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
19050 let (head, proposal_id, value) = signed_proposal_fixture();
19051
19052 let mut changed = value.clone();
19053 changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
19054 assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
19055
19056 let mut redirected = value.clone();
19057 redirected["proposal"]["blobs"][0]["endpoint"] =
19058 Value::String("https://attacker.example/blob".to_string());
19059 assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
19060
19061 let mut forged = value;
19062 let encoded = forged["proposal"]["submission_claim_base64"]
19063 .as_str()
19064 .unwrap();
19065 let mut envelope: Value =
19066 serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
19067 envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
19068 let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
19069 forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
19070 forged["proposal"]["submission_claim_sha256"] = Value::String(
19071 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
19072 );
19073 assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
19074 }
19075
19076 #[cfg(unix)]
19077 #[test]
19078 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
19079 let sandbox = tempfile::tempdir().unwrap();
19080 let destination = sandbox.path().join("brain");
19081 let entries = vec![
19082 (
19083 "DB.md".to_string(),
19084 scoped_projection_bytes(TEST_BRAIN_ID),
19085 ),
19086 (
19087 "records/contacts/a.md".to_string(),
19088 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
19089 .to_vec(),
19090 ),
19091 ];
19092 install_pulled_delta(&destination, &entries, &[], true).unwrap();
19093 assert!(destination.join("index.md").is_file());
19094 assert!(destination.join("records/index.md").is_file());
19095 assert!(destination.join("records/contacts/index.md").is_file());
19096 assert!(destination.join("records/contacts/index.jsonl").is_file());
19097 }
19098
19099 #[cfg(unix)]
19100 #[test]
19101 fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
19102 let sandbox = tempfile::tempdir().unwrap();
19103 let destination = sandbox.path().join("brain");
19104 let cache = sandbox.path().join("cache");
19105 std::fs::create_dir(&cache).unwrap();
19106 let db = scoped_projection_bytes(TEST_BRAIN_ID);
19107 let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
19108 let db_source = cache.join("db");
19109 let shared_source = cache.join("shared");
19110 crate::fsx::write_atomic(&db_source, &db).unwrap();
19111 crate::fsx::write_atomic(&shared_source, shared).unwrap();
19112 let mut entries = vec![V2StagedFile {
19113 path: "DB.md".to_string(),
19114 source: db_source,
19115 sha256: content_sha256(&db),
19116 bytes: db.len() as u64,
19117 }];
19118 for index in 0..512 {
19119 entries.push(V2StagedFile {
19120 path: format!("records/items/{index:05}.md"),
19121 source: shared_source.clone(),
19122 sha256: content_sha256(shared),
19123 bytes: shared.len() as u64,
19124 });
19125 }
19126 install_pulled_delta_sources(
19127 &destination,
19128 &entries,
19129 &[],
19130 false,
19131 None,
19132 &scoped_test_head(&"c".repeat(64)),
19133 )
19134 .unwrap();
19135 assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
19136 for index in 0..512 {
19137 assert_eq!(
19138 std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
19139 shared
19140 );
19141 }
19142 assert!(
19143 std::fs::read_dir(sandbox.path())
19144 .unwrap()
19145 .all(|entry| !entry
19146 .unwrap()
19147 .file_name()
19148 .to_string_lossy()
19149 .contains("pull-stage")),
19150 "the private stage must be atomically installed or removed"
19151 );
19152 }
19153
19154 #[cfg(unix)]
19155 #[test]
19156 fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
19157 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
19158
19159 let sandbox = tempfile::tempdir().unwrap();
19160 let root = sandbox.path().join("brain");
19161 std::fs::create_dir_all(root.join("records/items")).unwrap();
19162 let db = scoped_projection_bytes(TEST_BRAIN_ID);
19163 let old = b"---\ntype: note\n---\n\nold\n";
19164 let new = b"---\ntype: note\n---\n\nnew\n";
19165 let removed = b"---\ntype: note\n---\n\nremove me\n";
19166 std::fs::write(root.join("DB.md"), &db).unwrap();
19167 std::fs::write(root.join("records/items/change.md"), old).unwrap();
19168 std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
19169 for index in 0..512 {
19170 std::fs::write(
19171 root.join(format!("records/items/untouched-{index:04}.md")),
19172 old,
19173 )
19174 .unwrap();
19175 }
19176 let untouched = root.join("records/items/untouched-0256.md");
19177 let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
19178 let source = sandbox.path().join("changed-source");
19179 crate::fsx::write_atomic(&source, new).unwrap();
19180 let same_source = sandbox.path().join("unchanged-source");
19181 crate::fsx::write_atomic(&same_source, old).unwrap();
19182 let same_entry = V2StagedFile {
19183 path: "records/items/change.md".to_string(),
19184 source: same_source,
19185 sha256: content_sha256(old),
19186 bytes: old.len() as u64,
19187 };
19188 let entry = V2StagedFile {
19189 path: "records/items/change.md".to_string(),
19190 source,
19191 sha256: content_sha256(new),
19192 bytes: new.len() as u64,
19193 };
19194 let head = scoped_test_head(&"c".repeat(64));
19195
19196 install_established_v2_delta(
19200 Store::open_strict(&root).unwrap(),
19201 &[same_entry],
19202 &["records/items/already-absent.md".to_string()],
19203 true,
19204 None,
19205 &head,
19206 )
19207 .unwrap();
19208 assert_eq!(
19209 std::fs::metadata(&untouched).unwrap().ino(),
19210 untouched_inode
19211 );
19212 assert!(!root.join(V2_PULL_JOURNAL).exists());
19213
19214 install_established_v2_delta(
19215 Store::open_strict(&root).unwrap(),
19216 &[entry],
19217 &["records/items/delete.md".to_string()],
19218 false,
19219 None,
19220 &head,
19221 )
19222 .unwrap();
19223 assert_eq!(
19224 std::fs::read(root.join("records/items/change.md")).unwrap(),
19225 new
19226 );
19227 assert!(!root.join("records/items/delete.md").exists());
19228 assert_eq!(
19229 std::fs::metadata(&untouched).unwrap().ino(),
19230 untouched_inode
19231 );
19232 assert!(root.join(V2_PULL_JOURNAL).is_file());
19233 assert_eq!(
19234 std::fs::metadata(root.join(V2_PULL_JOURNAL))
19235 .unwrap()
19236 .permissions()
19237 .mode()
19238 & 0o777,
19239 0o600
19240 );
19241 let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
19242 .unwrap()
19243 .unwrap();
19244 assert_eq!(
19245 std::fs::metadata(root.join(&journal.backup_dir))
19246 .unwrap()
19247 .permissions()
19248 .mode()
19249 & 0o777,
19250 0o700
19251 );
19252 for entry in &journal.entries {
19253 if let Some(backup) = &entry.backup {
19254 assert_eq!(
19255 std::fs::metadata(root.join(&journal.backup_dir).join(backup))
19256 .unwrap()
19257 .permissions()
19258 .mode()
19259 & 0o777,
19260 0o600
19261 );
19262 }
19263 }
19264
19265 let cfg = test_hub_config(
19266 "https://example.test".to_string(),
19267 sandbox.path().join("state"),
19268 );
19269 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19270 assert_eq!(
19271 std::fs::read(root.join("records/items/change.md")).unwrap(),
19272 old
19273 );
19274 assert_eq!(
19275 std::fs::read(root.join("records/items/delete.md")).unwrap(),
19276 removed
19277 );
19278 assert_eq!(
19279 std::fs::metadata(&untouched).unwrap().ino(),
19280 untouched_inode
19281 );
19282 assert!(!root.join(V2_PULL_JOURNAL).exists());
19283 }
19284
19285 #[test]
19286 fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
19287 let body = b"bounded bytes";
19288 let path = "records/example.md".to_string();
19289 let file = V2BaselineFile {
19290 sha256: content_sha256(body),
19291 bytes: body.len() as u64,
19292 proof: None,
19293 };
19294 let header = serde_json::to_vec(&json!({
19295 "bytes": body.len(),
19296 "path": path,
19297 "sha256": file.sha256,
19298 "v": 2,
19299 }))
19300 .unwrap();
19301 let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
19302 stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
19303 stream.extend_from_slice(&header);
19304 stream.extend_from_slice(body);
19305 stream.extend_from_slice(&0_u32.to_be_bytes());
19306 let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
19307 assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
19308
19309 let mut tampered = stream.clone();
19310 let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
19311 tampered[body_offset] ^= 1;
19312 assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
19313
19314 let mut trailing = stream;
19315 trailing.push(0);
19316 assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
19317 }
19318
19319 #[test]
19320 fn first_checkout_resolution_does_not_recreate_the_same_conflict() {
19321 let path = "records/value.md".to_string();
19322 let mut local = std::collections::BTreeMap::new();
19323 local.insert(path.clone(), (content_sha256(b"local"), 5));
19324 let mut remote = std::collections::BTreeMap::new();
19325 remote.insert(
19326 path.clone(),
19327 V2BaselineFile {
19328 sha256: content_sha256(b"remote"),
19329 bytes: 6,
19330 proof: None,
19331 },
19332 );
19333
19334 assert_eq!(
19335 v2_initial_content_conflicts(&local, &remote, false),
19336 vec![path]
19337 );
19338 assert!(v2_initial_content_conflicts(&local, &remote, true).is_empty());
19339
19340 let mut resolution = std::collections::BTreeMap::new();
19341 resolution.insert(
19342 "records/value.md".to_string(),
19343 V2ResolutionOverride {
19344 expected_remote: Some(content_sha256(b"remote")),
19345 selected_local: Some(content_sha256(b"local")),
19346 },
19347 );
19348 assert!(v2_resolution_allows_path(
19349 Some(&resolution),
19350 "records/value.md",
19351 true
19352 ));
19353 assert!(v2_resolution_allows_path(
19354 Some(&resolution),
19355 "records/new-target.md",
19356 false
19357 ));
19358 assert!(!v2_resolution_allows_path(
19359 Some(&resolution),
19360 "records/unreviewed-remote.md",
19361 true
19362 ));
19363 }
19364
19365 #[test]
19366 fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
19367 let sandbox = tempfile::TempDir::new().unwrap();
19368 let root = sandbox.path().join("brain");
19369 std::fs::create_dir_all(&root).unwrap();
19370 std::fs::write(
19371 root.join("DB.md"),
19372 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19373 )
19374 .unwrap();
19375 let store = Store::open_strict(&root).unwrap();
19376 let incomplete = crate::ulid::mint();
19377 store
19378 .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
19379 .unwrap();
19380 let expired = crate::ulid::mint();
19381 store
19382 .create_dir_all(&v2_conflict_relative(&expired, "files"))
19383 .unwrap();
19384 let plan = V2ConflictPlan {
19385 v: 2,
19386 class: "content_resolution_required".to_string(),
19387 bundle: expired.clone(),
19388 brain: TEST_BRAIN_ID.to_string(),
19389 origin: "https://example.test".to_string(),
19390 created_unix: 0,
19391 expires_unix: 0,
19392 base_seq: None,
19393 base_commit: None,
19394 remote_seq: 0,
19395 remote_commit: None,
19396 remote_content_root: None,
19397 view_kind: "full".to_string(),
19398 view_revision: "a".repeat(64),
19399 files: vec![V2ConflictFile {
19400 path: "records/value.md".to_string(),
19401 base: V2ConflictCoordinate {
19402 sha256: None,
19403 bytes: None,
19404 file: None,
19405 },
19406 local: V2ConflictCoordinate {
19407 sha256: None,
19408 bytes: None,
19409 file: None,
19410 },
19411 remote: V2ConflictCoordinate {
19412 sha256: None,
19413 bytes: None,
19414 file: None,
19415 },
19416 }],
19417 };
19418 let mut bytes = serde_json::to_vec(&plan).unwrap();
19419 bytes.push(b'\n');
19420 store
19421 .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
19422 .unwrap();
19423
19424 let listed = sync_conflicts(&root, false, false).unwrap();
19425 assert_eq!(listed["bundles"], 2);
19426 assert_eq!(listed["pruned"], 0);
19427 let pruned = sync_conflicts(&root, true, false).unwrap();
19428 assert_eq!(pruned["bundles"], 0);
19429 assert_eq!(pruned["pruned"], 2);
19430 assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
19431 assert!(!root.join(".dbmd/conflicts").join(expired).exists());
19432 }
19433
19434 #[test]
19435 fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
19436 let sandbox = tempfile::TempDir::new().unwrap();
19437 let root = sandbox.path().join("brain");
19438 std::fs::create_dir_all(&root).unwrap();
19439 std::fs::write(
19440 root.join("DB.md"),
19441 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19442 )
19443 .unwrap();
19444 let store = Store::open_strict(&root).unwrap();
19445 let bundle = crate::ulid::mint();
19446 store
19447 .create_dir_all(&v2_conflict_relative(&bundle, "files"))
19448 .unwrap();
19449 store
19450 .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
19451 .unwrap();
19452
19453 assert!(sync_conflicts(&root, true, false).is_err());
19454 assert!(sync_conflicts(&root, false, true).is_err());
19455 let pruned = sync_conflicts(&root, true, true).unwrap();
19456 assert_eq!(pruned["pruned"], 1);
19457 assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
19458 }
19459
19460 #[test]
19461 fn ready_pull_journal_rolls_back_exact_preimages() {
19462 let sandbox = tempfile::TempDir::new().unwrap();
19463 let root = sandbox.path().join("brain");
19464 std::fs::create_dir_all(root.join("records")).unwrap();
19465 std::fs::write(
19466 root.join("DB.md"),
19467 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19468 )
19469 .unwrap();
19470 let path = "records/value.md";
19471 let old = b"---\ntype: note\n---\n\nold\n";
19472 let new = b"---\ntype: note\n---\n\nnew\n";
19473 std::fs::write(root.join(path), old).unwrap();
19474 let store = Store::open_strict(&root).unwrap();
19475 let bundle = crate::ulid::mint();
19476 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19477 store
19478 .create_private_dir_all(Path::new(&backup_dir))
19479 .unwrap();
19480 store
19481 .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
19482 .unwrap();
19483 let journal = V2PullJournal {
19484 v: 1,
19485 phase: V2PullPhase::Ready,
19486 brain: TEST_BRAIN_ID.to_string(),
19487 previous: V2PullCoordinate {
19488 head_seq: None,
19489 commit_hash: None,
19490 view_kind: None,
19491 view_revision: None,
19492 },
19493 next: V2PullCoordinate {
19494 head_seq: Some(2),
19495 commit_hash: Some("c".repeat(64)),
19496 view_kind: Some("full".to_string()),
19497 view_revision: Some("d".repeat(64)),
19498 },
19499 backup_dir: backup_dir.clone(),
19500 entries: vec![V2PullJournalEntry {
19501 path: path.to_string(),
19502 old: Some(V2PullFileCoordinate {
19503 sha256: content_sha256(old),
19504 bytes: old.len() as u64,
19505 }),
19506 new: Some(V2PullFileCoordinate {
19507 sha256: content_sha256(new),
19508 bytes: new.len() as u64,
19509 }),
19510 backup: Some("00000000".to_string()),
19511 }],
19512 };
19513 validate_v2_pull_journal(&journal).unwrap();
19514 store
19515 .write_private_atomic_new(
19516 Path::new(V2_PULL_JOURNAL),
19517 &v2_pull_journal_bytes(&journal).unwrap(),
19518 )
19519 .unwrap();
19520 store.write_atomic(Path::new(path), new).unwrap();
19521
19522 let cfg = test_hub_config(
19523 "https://example.test".to_string(),
19524 sandbox.path().join("state"),
19525 );
19526 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19527 assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
19528 assert!(!root.join(V2_PULL_JOURNAL).exists());
19529 assert!(!root.join(backup_dir).exists());
19530 }
19531
19532 #[test]
19533 fn preparing_pull_journal_discards_only_private_staging() {
19534 let sandbox = tempfile::TempDir::new().unwrap();
19535 let root = sandbox.path().join("brain");
19536 std::fs::create_dir_all(root.join(".dbmd")).unwrap();
19537 std::fs::write(
19538 root.join("DB.md"),
19539 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19540 )
19541 .unwrap();
19542 let store = Store::open_strict(&root).unwrap();
19543 let bundle = crate::ulid::mint();
19544 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19545 store
19546 .create_private_dir_all(Path::new(&backup_dir))
19547 .unwrap();
19548 let journal = V2PullJournal {
19549 v: 1,
19550 phase: V2PullPhase::Preparing,
19551 brain: TEST_BRAIN_ID.to_string(),
19552 previous: V2PullCoordinate {
19553 head_seq: None,
19554 commit_hash: None,
19555 view_kind: None,
19556 view_revision: None,
19557 },
19558 next: V2PullCoordinate {
19559 head_seq: Some(1),
19560 commit_hash: Some("a".repeat(64)),
19561 view_kind: Some("full".to_string()),
19562 view_revision: Some("b".repeat(64)),
19563 },
19564 backup_dir: backup_dir.clone(),
19565 entries: vec![V2PullJournalEntry {
19566 path: "records/new.md".to_string(),
19567 old: None,
19568 new: Some(V2PullFileCoordinate {
19569 sha256: "c".repeat(64),
19570 bytes: 1,
19571 }),
19572 backup: None,
19573 }],
19574 };
19575 store
19576 .write_private_atomic_new(
19577 Path::new(V2_PULL_JOURNAL),
19578 &v2_pull_journal_bytes(&journal).unwrap(),
19579 )
19580 .unwrap();
19581 let cfg = test_hub_config(
19582 "https://example.test".to_string(),
19583 sandbox.path().join("state"),
19584 );
19585
19586 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19587
19588 assert!(root.join("DB.md").is_file());
19589 assert!(!root.join(V2_PULL_JOURNAL).exists());
19590 assert!(!root.join(backup_dir).exists());
19591 }
19592
19593 #[test]
19594 fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
19595 let sandbox = tempfile::TempDir::new().unwrap();
19596 let root = sandbox.path().join("brain");
19597 std::fs::create_dir_all(root.join("records")).unwrap();
19598 std::fs::write(
19599 root.join("DB.md"),
19600 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19601 )
19602 .unwrap();
19603 let new = b"---\ntype: note\n---\n\nnew\n";
19604 std::fs::write(root.join("records/value.md"), new).unwrap();
19605 let store = Store::open_strict(&root).unwrap();
19606 let bundle = crate::ulid::mint();
19607 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19608 store
19609 .create_private_dir_all(Path::new(&backup_dir))
19610 .unwrap();
19611 let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
19612 store.create_private_dir_all(Path::new(&orphan)).unwrap();
19613 let next = V2PullCoordinate {
19614 head_seq: Some(2),
19615 commit_hash: Some("c".repeat(64)),
19616 view_kind: Some("full".to_string()),
19617 view_revision: Some("d".repeat(64)),
19618 };
19619 let journal = V2PullJournal {
19620 v: 1,
19621 phase: V2PullPhase::Ready,
19622 brain: TEST_BRAIN_ID.to_string(),
19623 previous: V2PullCoordinate {
19624 head_seq: Some(1),
19625 commit_hash: Some("a".repeat(64)),
19626 view_kind: Some("full".to_string()),
19627 view_revision: Some("b".repeat(64)),
19628 },
19629 next: next.clone(),
19630 backup_dir: backup_dir.clone(),
19631 entries: vec![V2PullJournalEntry {
19632 path: "records/value.md".to_string(),
19633 old: Some(V2PullFileCoordinate {
19634 sha256: "e".repeat(64),
19635 bytes: new.len() as u64,
19636 }),
19637 new: Some(V2PullFileCoordinate {
19638 sha256: content_sha256(new),
19639 bytes: new.len() as u64,
19640 }),
19641 backup: Some("00000000".to_string()),
19642 }],
19643 };
19644 store
19645 .write_private_atomic_new(
19646 Path::new(V2_PULL_JOURNAL),
19647 &v2_pull_journal_bytes(&journal).unwrap(),
19648 )
19649 .unwrap();
19650 let cfg = test_hub_config(
19651 "https://example.test".to_string(),
19652 sandbox.path().join("state"),
19653 );
19654 save_v2_baseline(
19655 &cfg,
19656 TEST_BRAIN_ID,
19657 &root,
19658 &V2SyncBaseline {
19659 v: 2,
19660 origin: "https://example.test".to_string(),
19661 brain: TEST_BRAIN_ID.to_string(),
19662 checkout_id: Some("c".repeat(64)),
19663 head_seq: next.head_seq,
19664 commit_hash: next.commit_hash.clone(),
19665 content_root: Some("f".repeat(64)),
19666 asset_root: None,
19667 assets: Default::default(),
19668 view_kind: next.view_kind.clone(),
19669 view_revision: next.view_revision.clone(),
19670 control_revision: Some("d".repeat(64)),
19671 projection_sha256: None,
19672 files: Default::default(),
19673 local_policy_digest: None,
19674 local_eligibility: Default::default(),
19675 remote_copy_remains: Default::default(),
19676 },
19677 )
19678 .unwrap();
19679
19680 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19681
19682 assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
19683 assert!(!root.join(V2_PULL_JOURNAL).exists());
19684 assert!(!root.join(backup_dir).exists());
19685 assert!(!root.join(orphan).exists());
19686 }
19687}