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_V2_BASELINE_BYTES: u64 = 64 * 1024 * 1024;
133const MAX_REGISTRY_CARD_BYTES: u64 = 1024 * 1024;
135
136const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
139const MAX_STAGED_CHANGE_BYTES: usize = 64 * 1024 * 1024;
142
143const MAX_PUSH_FILES: usize = u16::MAX as usize;
145const MAX_STORE_PATH_BYTES: usize = 1_024;
146const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
147const MAX_ASSET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
152const MAX_PACK_BYTES: u64 =
155 MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
156const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
165const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
166
167const MAX_IDENTITY_ROTATIONS: usize = 1_024;
170
171fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
174 let mut batches: Vec<Vec<Value>> = Vec::new();
175 let mut current: Vec<Value> = Vec::new();
176 let mut current_bytes = 0usize;
177 for declaration in declarations {
178 let declared_bytes = serde_json::to_string(&declaration)
179 .map(|text| text.len())
180 .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
181 + 1;
182 if !current.is_empty()
183 && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
184 || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
185 {
186 batches.push(std::mem::take(&mut current));
187 current_bytes = 0;
188 }
189 current_bytes += declared_bytes;
190 current.push(declaration);
191 }
192 if !current.is_empty() {
193 batches.push(current);
194 }
195 batches
196}
197const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
201const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
202const FEED_PAGE_LIMIT: usize = 100;
203
204pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
209
210const CONNECT_TIMEOUT_SECS: u64 = 10;
213const READ_TIMEOUT_SECS: u64 = 120;
214const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
218const COMMIT_REQUEST_TIMEOUT_SECS: u64 = 900;
225const COMMIT_ATTEMPTS: usize = 4;
229const COMMIT_RETRY_BACKOFF_MS: [u64; COMMIT_ATTEMPTS - 1] = [5_000, 20_000, 45_000];
230const CONNECT_ATTEMPTS: usize = 3;
231const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
232const SAFE_READ_ATTEMPTS: usize = 4;
237const SAFE_READ_RETRY_BACKOFF_MS: [u64; SAFE_READ_ATTEMPTS - 1] = [200, 1_000, 3_000];
238
239const UPLOAD_ATTEMPTS: usize = 6;
243const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
244const UPLOAD_TOTAL_TIMEOUT_SECS: u64 = 300;
248
249fn upload_retry_backoff_ms(attempt: usize) -> u64 {
250 UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
251}
252
253fn upload_deadline_error() -> LinkError {
254 LinkError::Transport {
255 hub: "the object store".to_string(),
256 message: "network error (upload deadline exceeded)".to_string(),
257 }
258}
259
260fn upload_attempt_timeout(deadline: std::time::Instant) -> LinkResult<std::time::Duration> {
261 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
262 if remaining.is_zero() {
263 return Err(upload_deadline_error());
264 }
265 Ok(remaining.min(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS)))
266}
267
268fn wait_for_upload_retry(deadline: std::time::Instant, attempt: usize) -> bool {
269 if attempt + 1 >= UPLOAD_ATTEMPTS {
270 return false;
271 }
272 let pause = std::time::Duration::from_millis(upload_retry_backoff_ms(attempt));
273 if deadline.saturating_duration_since(std::time::Instant::now()) <= pause {
274 return false;
275 }
276 std::thread::sleep(pause);
277 true
278}
279
280const RESERVATION_ATTEMPTS: usize = 7;
285const RESERVATION_BACKOFF_MS: [u64; RESERVATION_ATTEMPTS - 1] =
286 [500, 2_000, 5_000, 15_000, 30_000, 60_000];
287
288fn is_retryable_hub_status(status: u16) -> bool {
292 matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
293}
294
295fn is_retryable_upload_status(status: u16) -> bool {
299 matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
300}
301const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
305#[cfg(unix)]
309const V2_PULL_INSTALL_WORKERS: usize = 16;
310const V2_BULK_STREAM_FILES: usize = 256;
314const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
315const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
316const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
317const V2_DOWNLOAD_CAPABILITY_FILES: usize = V2_BLOB_DOWNLOAD_WORKERS;
322const V2_DOWNLOAD_CAPABILITY_BYTES: u64 = 512 * 1024 * 1024;
323const V2_DOWNLOAD_CAPABILITY_ATTEMPTS: usize = 4;
324const V2_DOWNLOAD_CAPABILITY_BACKOFF_MS: [u64; V2_DOWNLOAD_CAPABILITY_ATTEMPTS - 1] =
325 [200, 1_000, 3_000];
326
327#[derive(Debug, thiserror::Error)]
331pub enum LinkError {
332 #[error(
334 "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
335 )]
336 NoHub,
337
338 #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
340 NoCredential,
341
342 #[error(
345 "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
346 )]
347 BadKey,
348
349 #[error(
355 "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}"
356 )]
357 UnboundCredential,
358
359 #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
363 BadAgentKey {
364 message: String,
366 },
367
368 #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
370 UnsafeHub {
371 hub: String,
373 },
374
375 #[error("hub unreachable at {hub}: {message}")]
377 Transport {
378 hub: String,
380 message: String,
382 },
383
384 #[error("{what} failed (HTTP {status}): {message}")]
386 Http {
387 what: &'static str,
389 status: u16,
391 message: String,
393 code: Option<String>,
395 details: Option<Value>,
397 },
398
399 #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
402 NotJson {
403 what: &'static str,
405 status: u16,
407 },
408
409 #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
411 ResponseTooLarge {
412 limit_bytes: u64,
414 },
415
416 #[error("invalid address `{given}`: {reason}")]
418 BadAddress {
419 given: String,
421 reason: String,
423 },
424
425 #[error(
427 "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
428 )]
429 BadGrantId {
430 given: String,
432 },
433
434 #[error("refusing unsafe path from the hub: `{path}`")]
438 UnsafePath {
439 path: String,
441 },
442
443 #[error(
445 "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
446 MAX_STORE_BYTES / (1024 * 1024),
447 MAX_PACK_BYTES / (1024 * 1024)
448 )]
449 PushTooLarge {
450 detail: String,
452 },
453
454 #[error(
456 "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
457 MAX_PROPOSE_BYTES / 1024
458 )]
459 ProposeTooLarge {
460 bytes: u64,
462 },
463
464 #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
466 NotUtf8 {
467 path: String,
469 },
470
471 #[error("invalid store pack: {message}")]
473 InvalidPack {
474 message: String,
476 },
477
478 #[error("invalid signed feed: {message}")]
480 InvalidFeed {
481 message: String,
483 },
484
485 #[error(
489 "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}`"
490 )]
491 AliasRebindRequired {
492 alias: String,
493 from: String,
494 to: String,
495 },
496
497 #[error("sync conflict on {paths:?} — resolve the named files and retry")]
500 Conflict {
501 paths: Vec<String>,
503 },
504
505 #[error(
509 "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
510 )]
511 ConflictBundle {
512 bundle: String,
514 paths: Vec<String>,
516 },
517
518 #[error(
522 "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
523 )]
524 LocalPolicyTransition {
525 paths: Vec<String>,
527 },
528
529 #[error(
533 "hosted assets require explicit withdrawal {paths:?} — retry with one --withdraw-from-hosting <path> per asset and a non-empty --withdraw-reason"
534 )]
535 AssetWithdrawalRequired {
536 paths: Vec<String>,
538 },
539
540 #[error(
545 "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
546 )]
547 BulkPreviewRequired {
548 preview: Value,
550 },
551
552 #[error(
555 "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
556 )]
557 ScopedProjectionModified,
558
559 #[error(
563 "the checkout's permission scope changed — clone into a new directory to accept the new view"
564 )]
565 ScopedViewChanged,
566
567 #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
570 BrainUnavailable,
571
572 #[error(
575 "the remote brain advanced during sync — retry to converge from the new verified head"
576 )]
577 RemoteAdvancedDuringSync,
578
579 #[error(
582 "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
583 )]
584 UnsupportedPlatform {
585 operation: &'static str,
587 },
588
589 #[error(transparent)]
591 Io(#[from] std::io::Error),
592
593 #[error(transparent)]
595 Store(#[from] crate::StoreError),
596}
597
598pub type LinkResult<T> = std::result::Result<T, LinkError>;
600
601#[derive(Debug, Clone, PartialEq, Eq)]
603pub struct V2BulkConfirmation {
604 pub id: String,
606 pub digest: String,
609}
610
611impl V2BulkConfirmation {
612 pub fn parse(value: &str) -> LinkResult<Self> {
615 let (id, digest) = value
616 .split_once(':')
617 .ok_or_else(|| LinkError::InvalidPack {
618 message: "bulk confirmation must be <id>:<digest>".to_string(),
619 })?;
620 if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
621 return Err(LinkError::InvalidPack {
622 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
623 .to_string(),
624 });
625 }
626 Ok(Self {
627 id: id.to_string(),
628 digest: digest.to_string(),
629 })
630 }
631}
632
633fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
638 #[cfg(any(target_os = "linux", target_os = "macos", windows))]
639 {
640 let _ = operation;
641 Ok(())
642 }
643 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
644 {
645 Err(LinkError::UnsupportedPlatform { operation })
646 }
647}
648
649#[derive(Debug, Clone, PartialEq, Eq)]
655pub enum AddressTarget {
656 Id(String),
658 Path(String),
662}
663
664const BAD_BRAIN_REASON: &str =
667 "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
668
669const BAD_TARGET_REASON: &str =
672 "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
673
674#[derive(Debug, Clone, PartialEq, Eq)]
679pub struct Address {
680 pub brain: String,
682 pub target: Option<AddressTarget>,
684}
685
686impl Address {
687 pub fn parse(raw: &str) -> LinkResult<Address> {
691 let bad = |reason: &str| LinkError::BadAddress {
692 given: raw.to_string(),
693 reason: reason.to_string(),
694 };
695
696 let trimmed = raw.trim();
697 let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
698 if body.is_empty() {
699 return Err(bad("empty address"));
700 }
701
702 let (brain, rest) = match body.split_once('/') {
703 Some((b, r)) => (b, Some(r)),
704 None => (body, None),
705 };
706
707 if brain.is_empty() {
708 return Err(bad("missing brain reference before `/`"));
709 }
710 if !is_safe_ref(brain) {
711 return Err(bad(BAD_BRAIN_REASON));
712 }
713
714 let target = match rest {
715 None => None,
716 Some("") => return Err(bad("trailing `/` with no record id or path")),
717 Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
718 Some(r) => {
719 if !safe_store_rel_path(r) || !r.ends_with(".md") {
720 return Err(bad(BAD_TARGET_REASON));
721 }
722 Some(AddressTarget::Path(r.to_string()))
723 }
724 };
725
726 Ok(Address {
727 brain: brain.to_string(),
728 target,
729 })
730 }
731}
732
733fn is_safe_ref(s: &str) -> bool {
736 !s.is_empty()
737 && s.len() <= 64
738 && s.bytes()
739 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
740}
741
742pub fn is_valid_handle(s: &str) -> bool {
745 is_safe_ref(s)
746}
747
748pub fn safe_store_rel_path(p: &str) -> bool {
754 if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
755 return false;
756 }
757 if !p
758 .bytes()
759 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
760 {
761 return false;
762 }
763 p.split('/')
764 .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
765}
766
767fn require_safe_ref(brain: &str) -> LinkResult<()> {
775 if is_safe_ref(brain) {
776 Ok(())
777 } else {
778 Err(LinkError::BadAddress {
779 given: brain.to_string(),
780 reason: BAD_BRAIN_REASON.to_string(),
781 })
782 }
783}
784
785fn require_valid_handle(handle: &str) -> LinkResult<()> {
787 if is_valid_handle(handle) {
788 Ok(())
789 } else {
790 Err(LinkError::BadAddress {
791 given: handle.to_string(),
792 reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
793 })
794 }
795}
796
797fn require_safe_grant_id(id: &str) -> LinkResult<()> {
801 if is_safe_ref(id) {
802 Ok(())
803 } else {
804 Err(LinkError::BadGrantId {
805 given: id.to_string(),
806 })
807 }
808}
809
810#[derive(Debug, Clone)]
816pub struct HubConfig {
817 pub hub: String,
819 pub key: Option<String>,
821 pub agent_key: Option<AgentSigningKey>,
824 pub brain_key: Option<AgentSigningKey>,
827 pub state_dir: PathBuf,
830 store_selected: bool,
833}
834
835#[derive(Clone)]
838pub struct AgentSigningKey {
839 pkcs8: Vec<u8>,
840 pub multikey: String,
842 pub public_key_spki: String,
844}
845
846impl std::fmt::Debug for AgentSigningKey {
847 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
848 f.debug_struct("AgentSigningKey")
849 .field("multikey", &self.multikey)
850 .field("pkcs8", &"<redacted>")
851 .finish()
852 }
853}
854
855impl HubConfig {
856 pub fn require_key(&self) -> LinkResult<&str> {
859 self.key.as_deref().ok_or(LinkError::NoCredential)
860 }
861}
862
863pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
868 let explicit_hub = flag_hub
869 .map(str::to_string)
870 .or_else(|| env_nonempty(HUB_URL_ENV));
871 let selected_by_store = explicit_hub.is_none();
872 let hub = explicit_hub
873 .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
874 .ok_or(LinkError::NoHub)?;
875 let hub = hub.trim().trim_end_matches('/').to_string();
876 assert_safe_hub(&hub)?;
877 if selected_by_store {
878 let parsed =
879 url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
880 if !parsed.scheme().eq_ignore_ascii_case("https")
884 || (parsed.path() != "/" && !parsed.path().is_empty())
885 {
886 return Err(LinkError::UnsafeHub { hub });
887 }
888 }
889
890 let key = match env_nonempty(HUB_KEY_ENV) {
891 Some(raw) => Some(clean_key(&raw)?),
892 None => None,
893 };
894
895 let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
896 Some(path) => Some(load_agent_key(Path::new(&path))?),
897 None => None,
898 };
899
900 let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
901 Some(path) => Some(load_agent_key(Path::new(&path))?),
902 None => None,
903 };
904
905 if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
912 let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
913 .and_then(|value| normalized_origin(&value).ok());
914 let selected_origin = normalized_origin(&hub)?;
915 if bound.as_deref() != Some(selected_origin.as_str()) {
916 return Err(LinkError::UnboundCredential);
917 }
918 }
919
920 Ok(HubConfig {
921 hub,
922 key,
923 agent_key,
924 brain_key,
925 state_dir: toolkit_state_dir()?,
926 store_selected: selected_by_store,
927 })
928}
929
930fn toolkit_state_dir() -> LinkResult<PathBuf> {
931 if let Some(path) = env_nonempty(STATE_DIR_ENV) {
932 let path = PathBuf::from(path);
933 if !path.is_absolute() {
934 return Err(LinkError::UnsafePath {
935 path: path.display().to_string(),
936 });
937 }
938 return Ok(path);
939 }
940 #[cfg(windows)]
941 if let Some(base) = env_nonempty("LOCALAPPDATA") {
942 let base = PathBuf::from(base);
943 if base.is_absolute() {
944 return Ok(base.join("dbmd").join("state"));
945 }
946 }
947 #[cfg(windows)]
948 {
949 Err(LinkError::Io(std::io::Error::new(
950 std::io::ErrorKind::NotFound,
951 format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
952 )))
953 }
954 #[cfg(not(windows))]
955 if let Some(base) = env_nonempty("XDG_STATE_HOME") {
956 let base = PathBuf::from(base);
957 if base.is_absolute() {
958 return Ok(base.join("dbmd"));
959 }
960 }
961 #[cfg(not(windows))]
962 let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
963 LinkError::Io(std::io::Error::new(
964 std::io::ErrorKind::NotFound,
965 format!("cannot locate user state; set {STATE_DIR_ENV}"),
966 ))
967 })?);
968 #[cfg(not(windows))]
969 if !home.is_absolute() {
970 return Err(LinkError::UnsafePath {
971 path: home.display().to_string(),
972 });
973 }
974 #[cfg(target_os = "macos")]
975 {
976 Ok(home
977 .join("Library")
978 .join("Application Support")
979 .join("dbmd")
980 .join("state"))
981 }
982 #[cfg(all(not(target_os = "macos"), not(windows)))]
983 {
984 Ok(home.join(".local").join("state").join("dbmd"))
985 }
986}
987
988fn normalized_origin(value: &str) -> LinkResult<String> {
989 let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
990 hub: value.to_string(),
991 })?;
992 if !(parsed.scheme().eq_ignore_ascii_case("https")
993 || parsed.scheme().eq_ignore_ascii_case("http"))
994 || !parsed.username().is_empty()
995 || parsed.password().is_some()
996 || (parsed.path() != "/" && !parsed.path().is_empty())
997 || parsed.query().is_some()
998 || parsed.fragment().is_some()
999 {
1000 return Err(LinkError::UnsafeHub {
1001 hub: value.to_string(),
1002 });
1003 }
1004 let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
1005 hub: value.to_string(),
1006 })?;
1007 let host = if host.contains(':') {
1008 format!("[{host}]")
1009 } else {
1010 host.to_ascii_lowercase()
1011 };
1012 let port = parsed
1013 .port_or_known_default()
1014 .ok_or_else(|| LinkError::UnsafeHub {
1015 hub: value.to_string(),
1016 })?;
1017 let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
1018 || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
1019 Ok(format!(
1020 "{}://{}{}",
1021 parsed.scheme().to_ascii_lowercase(),
1022 host,
1023 if default {
1024 String::new()
1025 } else {
1026 format!(":{port}")
1027 }
1028 ))
1029}
1030
1031const ED25519_SPKI_PREFIX: [u8; 12] = [
1038 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1039];
1040
1041fn bad_agent_key(message: &str) -> LinkError {
1042 LinkError::BadAgentKey {
1043 message: message.to_string(),
1044 }
1045}
1046
1047fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
1048 ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
1052 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
1053 .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
1054}
1055
1056fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
1058 use ring::signature::KeyPair as _;
1059 let mut spki = Vec::with_capacity(44);
1060 spki.extend_from_slice(&ED25519_SPKI_PREFIX);
1061 spki.extend_from_slice(pair.public_key().as_ref());
1062 (
1063 URL_SAFE_NO_PAD.encode(&spki),
1064 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
1065 )
1066}
1067
1068pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1072 load_agent_key(path)
1073}
1074
1075fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1077 #[cfg(unix)]
1078 let file = {
1079 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1080 use std::os::unix::ffi::OsStrExt as _;
1081 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1082 .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1083 let leaf = path
1084 .file_name()
1085 .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1086 let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1087 let fd = unsafe {
1088 libc::openat(
1089 parent.as_raw_fd(),
1090 leaf.as_ptr(),
1091 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1092 )
1093 };
1094 if fd < 0 {
1095 return Err(bad_agent_key(
1096 "the key path must be an existing regular file without symlink ancestors",
1097 ));
1098 }
1099 unsafe { std::fs::File::from_raw_fd(fd) }
1100 };
1101 #[cfg(not(unix))]
1102 let file = std::fs::File::open(path)
1103 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1104 let metadata = file
1105 .metadata()
1106 .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1107 if !metadata.is_file() {
1108 return Err(bad_agent_key("the key path must be a regular file"));
1109 }
1110 #[cfg(unix)]
1111 {
1112 use std::os::unix::fs::PermissionsExt as _;
1113 if metadata.permissions().mode() & 0o077 != 0 {
1114 return Err(bad_agent_key(
1115 "the key file is accessible to group/other; set mode 0600",
1116 ));
1117 }
1118 }
1119 let mut text = String::new();
1120 file.take(1024 * 1024 + 1)
1121 .read_to_string(&mut text)
1122 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1123 if text.len() > 1024 * 1024 {
1124 return Err(bad_agent_key("the key file exceeds the size limit"));
1125 }
1126 let pkcs8 = URL_SAFE_NO_PAD
1127 .decode(text.trim())
1128 .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1129 let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1130 Ok(AgentSigningKey {
1131 pkcs8,
1132 multikey,
1133 public_key_spki,
1134 })
1135}
1136
1137fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1143 #[cfg(unix)]
1144 let (mut file, parent, leaf) = {
1145 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1146 use std::os::unix::ffi::OsStrExt as _;
1147 let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1148 let leaf_name = path
1149 .file_name()
1150 .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1151 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1152 let fd = unsafe {
1153 libc::openat(
1154 parent.as_raw_fd(),
1155 leaf.as_ptr(),
1156 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1157 0o600,
1158 )
1159 };
1160 if fd < 0 {
1161 let error = std::io::Error::last_os_error();
1162 if error.kind() == std::io::ErrorKind::AlreadyExists {
1163 return Err(bad_agent_key(
1164 "the output file already exists — refusing to overwrite a key",
1165 ));
1166 }
1167 return Err(error.into());
1168 }
1169 (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1170 };
1171 #[cfg(not(unix))]
1172 let mut file = std::fs::OpenOptions::new()
1173 .write(true)
1174 .create_new(true)
1175 .open(path)
1176 .map_err(|error| {
1177 if error.kind() == std::io::ErrorKind::AlreadyExists {
1178 bad_agent_key("the output file already exists — refusing to overwrite a key")
1179 } else {
1180 LinkError::Io(error)
1181 }
1182 })?;
1183 if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1184 drop(file);
1185 #[cfg(unix)]
1186 let _ =
1187 unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1188 #[cfg(not(unix))]
1189 let _ = std::fs::remove_file(path);
1190 return Err(LinkError::Io(error));
1191 }
1192 drop(file);
1193 #[cfg(unix)]
1194 parent.sync_all()?;
1195 Ok(())
1196}
1197
1198#[derive(Debug, Serialize)]
1201pub struct GeneratedAgentKey {
1202 pub multikey: String,
1204 #[serde(rename = "publicKeySpki")]
1206 pub public_key_spki: String,
1207 #[serde(rename = "keyFile")]
1209 pub key_file: String,
1210}
1211
1212pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1217 require_hardened_filesystem("key generation")?;
1218 let rng = ring::rand::SystemRandom::new();
1219 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1220 .map_err(|_| bad_agent_key("key generation failed"))?;
1221 let pair = agent_keypair(pkcs8.as_ref())?;
1222 let (spki_b64u, multikey) = public_identity_for(&pair);
1223
1224 write_secret_new(
1225 out,
1226 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1227 )?;
1228
1229 Ok(GeneratedAgentKey {
1230 multikey,
1231 public_key_spki: spki_b64u,
1232 key_file: out.display().to_string(),
1233 })
1234}
1235
1236fn linkmd_sig_header(
1245 key: &AgentSigningKey,
1246 origin: &str,
1247 method: &str,
1248 path: &str,
1249 body: Option<&str>,
1250) -> LinkResult<String> {
1251 let ts = std::time::SystemTime::now()
1252 .duration_since(std::time::UNIX_EPOCH)
1253 .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1254 .as_secs();
1255 let body_hash = match body {
1256 Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1257 None => "-".to_string(),
1258 };
1259 let canonical = format!(
1260 "v2\n{}\n{}\n{}\n{}\n{}",
1261 origin,
1262 method.to_uppercase(),
1263 path,
1264 ts,
1265 body_hash
1266 );
1267 let pair = agent_keypair(&key.pkcs8)?;
1268 let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1269 let fingerprint = key.multikey.trim_start_matches("ed25519:");
1270 Ok(format!(
1271 "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1272 ))
1273}
1274
1275#[derive(Serialize)]
1282struct WireFeedFile {
1283 path: String,
1284 sha256: String,
1285 bytes: u64,
1286}
1287
1288#[derive(Serialize)]
1291struct UnsignedWireEntry<'a> {
1292 v: u8,
1293 seq: u64,
1294 ts: String,
1295 brain: &'a str,
1296 public_key: &'a str,
1297 kind: &'a str,
1298 op: &'a str,
1299 pack_sha256: &'a str,
1300 files: &'a [WireFeedFile],
1301 removed: &'a [String],
1302 prev_entry_hash: Option<&'a str>,
1303}
1304
1305fn self_custody_entry(
1311 key: &AgentSigningKey,
1312 seq: u64,
1313 ts: String,
1314 pack_sha256: &str,
1315 files: &[WireFeedFile],
1316 prev_entry_hash: Option<&str>,
1317) -> LinkResult<String> {
1318 let removed: [String; 0] = [];
1319 let unsigned = serde_json::to_string(&UnsignedWireEntry {
1320 v: 1,
1321 seq,
1322 ts,
1323 brain: &key.multikey,
1324 public_key: &key.public_key_spki,
1325 kind: "push",
1326 op: "snapshot",
1327 pack_sha256,
1328 files,
1329 removed: &removed,
1330 prev_entry_hash,
1331 })
1332 .expect("serialize feed entry");
1333 let pair = agent_keypair(&key.pkcs8)?;
1334 let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1335 Ok(format!(
1336 "{},\"sig\":\"{}\"}}",
1337 &unsigned[..unsigned.len() - 1],
1338 sig
1339 ))
1340}
1341
1342fn env_nonempty(name: &str) -> Option<String> {
1345 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1346}
1347
1348fn config_file_hub(path: &Path) -> Option<String> {
1353 const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1354 #[cfg(unix)]
1355 let file = {
1356 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1357 use std::os::unix::ffi::OsStrExt as _;
1358 let parent =
1359 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1360 let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1361 let fd = unsafe {
1362 libc::openat(
1363 parent.as_raw_fd(),
1364 leaf.as_ptr(),
1365 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1366 )
1367 };
1368 if fd < 0 {
1369 return None;
1370 }
1371 unsafe { std::fs::File::from_raw_fd(fd) }
1372 };
1373 #[cfg(not(unix))]
1374 let file = std::fs::File::open(path).ok()?;
1375 let metadata = file.metadata().ok()?;
1376 if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1377 return None;
1378 }
1379 let mut bytes = Vec::with_capacity(metadata.len() as usize);
1380 file.take(MAX_CONFIG_BYTES + 1)
1381 .read_to_end(&mut bytes)
1382 .ok()?;
1383 if bytes.len() as u64 > MAX_CONFIG_BYTES {
1384 return None;
1385 }
1386 let text = String::from_utf8(bytes).ok()?;
1387 for line in text.lines() {
1388 let line = line.trim();
1389 if line.is_empty() || line.starts_with('#') {
1390 continue;
1391 }
1392 if let Some((k, v)) = line.split_once('=') {
1393 if k.trim() == "hub" {
1394 let v = v.trim();
1395 if !v.is_empty() {
1396 return Some(v.to_string());
1397 }
1398 }
1399 }
1400 }
1401 None
1402}
1403
1404fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1407 let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1408 hub: hub.to_string(),
1409 })?;
1410 if !(parsed.scheme().eq_ignore_ascii_case("https")
1411 || parsed.scheme().eq_ignore_ascii_case("http"))
1412 || !parsed.username().is_empty()
1413 || parsed.password().is_some()
1414 || (parsed.path() != "/" && !parsed.path().is_empty())
1415 || parsed.query().is_some()
1416 || parsed.fragment().is_some()
1417 {
1418 return Err(LinkError::UnsafeHub {
1419 hub: hub.to_string(),
1420 });
1421 }
1422 let loopback = match parsed.host() {
1423 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1424 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1425 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1426 None => false,
1427 };
1428 if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1429 Ok(())
1430 } else {
1431 Err(LinkError::UnsafeHub {
1432 hub: hub.to_string(),
1433 })
1434 }
1435}
1436
1437fn clean_key(raw: &str) -> LinkResult<String> {
1442 let k = raw.trim();
1443 if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1444 return Err(LinkError::BadKey);
1445 }
1446 Ok(k.to_string())
1447}
1448
1449#[derive(Debug)]
1455pub struct HubResponse {
1456 pub status: u16,
1458 pub body: Option<Value>,
1460}
1461
1462struct RawHubResponse {
1463 status: u16,
1464 body: Vec<u8>,
1465}
1466
1467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1469enum Auth {
1470 Required,
1472 None,
1474 Optional,
1478}
1479
1480fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1481 ureq::AgentBuilder::new()
1482 .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1483 .redirects(0)
1487 .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1488 .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1489 .timeout_write(overall)
1490 .timeout(overall)
1491}
1492
1493fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1494 hub_agent_with_timeout(
1495 cfg,
1496 std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1497 )
1498}
1499
1500fn hub_agent_with_timeout(
1501 cfg: &HubConfig,
1502 overall: std::time::Duration,
1503) -> LinkResult<ureq::Agent> {
1504 if !cfg.store_selected {
1505 return Ok(agent_builder_with_timeout(overall).build());
1506 }
1507 let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1508 hub: cfg.hub.clone(),
1509 })?;
1510 pinned_public_agent_pooled(
1511 &parsed,
1512 false,
1513 "store-selected hub",
1514 AgentShape {
1515 overall,
1516 ..AgentShape::default()
1517 },
1518 )
1519}
1520
1521fn request_raw(
1526 cfg: &HubConfig,
1527 method: &str,
1528 path: &str,
1529 body: Option<&Value>,
1530 auth: Auth,
1531 max_response_bytes: u64,
1532) -> LinkResult<RawHubResponse> {
1533 let http = hub_agent(cfg)?;
1534 request_raw_with_agent(
1535 cfg,
1536 &http,
1537 method,
1538 path,
1539 body,
1540 RawRequestOptions {
1541 auth,
1542 max_response_bytes,
1543 request_id: None,
1544 retry_transport: false,
1545 },
1546 )
1547}
1548
1549fn request_raw_retryable_read(
1553 cfg: &HubConfig,
1554 method: &str,
1555 path: &str,
1556 body: Option<&Value>,
1557 auth: Auth,
1558 max_response_bytes: u64,
1559) -> LinkResult<RawHubResponse> {
1560 let http = hub_agent(cfg)?;
1561 request_raw_with_agent(
1562 cfg,
1563 &http,
1564 method,
1565 path,
1566 body,
1567 RawRequestOptions {
1568 auth,
1569 max_response_bytes,
1570 request_id: None,
1571 retry_transport: true,
1572 },
1573 )
1574}
1575
1576struct RawRequestOptions<'a> {
1577 auth: Auth,
1578 max_response_bytes: u64,
1579 request_id: Option<&'a str>,
1580 retry_transport: bool,
1581}
1582
1583fn request_raw_with_agent(
1584 cfg: &HubConfig,
1585 http: &ureq::Agent,
1586 method: &str,
1587 path: &str,
1588 body: Option<&Value>,
1589 options: RawRequestOptions<'_>,
1590) -> LinkResult<RawHubResponse> {
1591 let url = format!("{}{}", cfg.hub, path);
1592 let encoded_body = body.map(Value::to_string);
1593 let origin = normalized_origin(&cfg.hub)?;
1594 let safe_read = (method == "GET" && encoded_body.is_none()) || options.retry_transport;
1595 let mut read_attempt = 0;
1596 loop {
1597 let credential = match options.auth {
1604 Auth::Required => Some(match &cfg.agent_key {
1605 Some(key) => {
1606 linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?
1607 }
1608 None => format!("Bearer {}", cfg.require_key()?),
1609 }),
1610 Auth::Optional => match &cfg.agent_key {
1611 Some(key) => Some(linkmd_sig_header(
1612 key,
1613 &origin,
1614 method,
1615 path,
1616 encoded_body.as_deref(),
1617 )?),
1618 None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1619 },
1620 Auth::None => None,
1621 };
1622 let result = with_connect_retries(|| {
1623 let mut req = http.request(method, &url);
1624 if let Some(value) = &credential {
1625 req = req.set("authorization", value);
1626 }
1627 if let Some(value) = options.request_id {
1628 req = req.set("x-request-id", value);
1629 }
1630 match &encoded_body {
1631 Some(value) => req
1632 .set("content-type", "application/json")
1633 .send_string(value)
1634 .map_err(Box::new),
1635 None => req.call().map_err(Box::new),
1636 }
1637 });
1638 let resp = match result {
1639 Ok(resp) => resp,
1640 Err(error) => match *error {
1641 ureq::Error::Status(_, resp) => resp,
1642 ureq::Error::Transport(error) => {
1643 if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS {
1644 std::thread::sleep(std::time::Duration::from_millis(
1645 SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1646 ));
1647 read_attempt += 1;
1648 continue;
1649 }
1650 return Err(LinkError::Transport {
1651 hub: cfg.hub.clone(),
1652 message: error.to_string(),
1653 });
1654 }
1655 },
1656 };
1657
1658 let status = resp.status();
1659 let buf = match read_response_body(resp, options.max_response_bytes + 1, &cfg.hub) {
1660 Ok(buf) => buf,
1661 Err(LinkError::Transport { .. })
1662 if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS =>
1663 {
1664 std::thread::sleep(std::time::Duration::from_millis(
1665 SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1666 ));
1667 read_attempt += 1;
1668 continue;
1669 }
1670 Err(error) => return Err(error),
1671 };
1672 if buf.len() as u64 > options.max_response_bytes {
1673 return Err(LinkError::ResponseTooLarge {
1674 limit_bytes: options.max_response_bytes,
1675 });
1676 }
1677 return Ok(RawHubResponse { status, body: buf });
1678 }
1679}
1680
1681fn request_capped(
1682 cfg: &HubConfig,
1683 method: &str,
1684 path: &str,
1685 body: Option<&Value>,
1686 auth: Auth,
1687 max_response_bytes: u64,
1688) -> LinkResult<HubResponse> {
1689 let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1690 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1691 Ok(HubResponse {
1692 status: raw.status,
1693 body: parsed,
1694 })
1695}
1696
1697fn request_patient(
1709 cfg: &HubConfig,
1710 method: &str,
1711 path: &str,
1712 body: Option<&Value>,
1713 auth: Auth,
1714) -> LinkResult<HubResponse> {
1715 let http = hub_agent_with_timeout(
1716 cfg,
1717 std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1718 )?;
1719 let mut attempt = 0;
1720 let validation_started = std::time::Instant::now();
1721 let mut validation_wait = std::time::Duration::from_millis(250);
1722 loop {
1723 let sent = request_raw_with_agent(
1724 cfg,
1725 &http,
1726 method,
1727 path,
1728 body,
1729 RawRequestOptions {
1730 auth,
1731 max_response_bytes: MAX_RESPONSE_BYTES,
1732 request_id: None,
1733 retry_transport: false,
1734 },
1735 );
1736 match sent {
1737 Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1738 std::thread::sleep(std::time::Duration::from_millis(
1739 COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1740 ));
1741 attempt += 1;
1742 }
1743 Err(error) => return Err(error),
1744 Ok(raw) => {
1745 let response = HubResponse {
1746 status: raw.status,
1747 body: serde_json::from_slice(&raw.body).ok(),
1748 };
1749 if v2_validation_catching_up(&response) {
1756 if validation_started.elapsed() >= std::time::Duration::from_secs(15 * 60) {
1757 return Err(LinkError::Http {
1758 what: "v2 commit receipt",
1759 status: response.status,
1760 message: "validation/index recovery did not make the exact mutation receipt available within 15 minutes".to_string(),
1761 code: Some("validation_index_catching_up".to_string()),
1762 details: response.body,
1763 });
1764 }
1765 std::thread::sleep(validation_wait);
1766 validation_wait = validation_wait
1767 .saturating_mul(2)
1768 .min(std::time::Duration::from_secs(5));
1769 continue;
1770 }
1771 return Ok(response);
1772 }
1773 }
1774 }
1775}
1776
1777fn request(
1778 cfg: &HubConfig,
1779 method: &str,
1780 path: &str,
1781 body: Option<&Value>,
1782 auth: Auth,
1783) -> LinkResult<HubResponse> {
1784 request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1785}
1786
1787fn request_with_request_id(
1792 cfg: &HubConfig,
1793 method: &str,
1794 path: &str,
1795 body: Option<&Value>,
1796 auth: Auth,
1797 request_id: &str,
1798) -> LinkResult<HubResponse> {
1799 if request_id.is_empty()
1800 || request_id.len() > 128
1801 || !request_id
1802 .bytes()
1803 .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1804 {
1805 return Err(invalid_feed("hub returned an unsafe request id"));
1806 }
1807 let http = hub_agent_with_timeout(
1810 cfg,
1811 std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1812 )?;
1813 let raw = request_raw_with_agent(
1814 cfg,
1815 &http,
1816 method,
1817 path,
1818 body,
1819 RawRequestOptions {
1820 auth,
1821 max_response_bytes: MAX_RESPONSE_BYTES,
1822 request_id: Some(request_id),
1823 retry_transport: false,
1824 },
1825 )?;
1826 Ok(HubResponse {
1827 status: raw.status,
1828 body: serde_json::from_slice(&raw.body).ok(),
1829 })
1830}
1831
1832fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1833 if (200..300).contains(&r.status) {
1834 return Ok(r.body);
1835 }
1836 ensure_ok(
1837 HubResponse {
1838 status: r.status,
1839 body: serde_json::from_slice(&r.body).ok(),
1840 },
1841 what,
1842 )
1843 .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1844}
1845
1846fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1851 matches!(
1852 kind,
1853 ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1854 )
1855}
1856
1857fn with_connect_retries(
1858 mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1859) -> Result<ureq::Response, Box<ureq::Error>> {
1860 let mut attempt = 0;
1861 loop {
1862 match send() {
1863 Err(error)
1864 if matches!(
1865 error.as_ref(),
1866 ureq::Error::Transport(transport)
1867 if is_pre_request_transport(transport.kind())
1868 ) && attempt + 1 < CONNECT_ATTEMPTS =>
1869 {
1870 std::thread::sleep(std::time::Duration::from_millis(
1871 CONNECT_RETRY_BACKOFF_MS[attempt],
1872 ));
1873 attempt += 1;
1874 }
1875 result => return result,
1876 }
1877 }
1878}
1879
1880fn hub_is_loopback(hub: &str) -> bool {
1881 url::Url::parse(hub).ok().is_some_and(|parsed| {
1882 parsed.host().is_some_and(|host| match host {
1883 url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1884 url::Host::Ipv4(ip) => ip.is_loopback(),
1885 url::Host::Ipv6(ip) => ip.is_loopback(),
1886 })
1887 })
1888}
1889
1890fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1894 let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1895 message: "the hub returned an invalid object-store URL".to_string(),
1896 })?;
1897 let allow_private = hub_is_loopback(&cfg.hub)
1898 || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1899 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1900 || !parsed.username().is_empty()
1901 || parsed.password().is_some()
1902 || parsed.fragment().is_some()
1903 {
1904 return Err(LinkError::InvalidPack {
1905 message: "the hub returned an unsafe object-store URL".to_string(),
1906 });
1907 }
1908 Ok((parsed, allow_private))
1909}
1910
1911fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1912 let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1913 pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1914 LinkError::InvalidPack {
1915 message: "the hub returned an object-store URL with an unsafe network target"
1916 .to_string(),
1917 }
1918 })
1919}
1920
1921fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1930 let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1931 let authority = (
1932 first.host_str()?.to_string(),
1933 first.port_or_known_default()?,
1934 );
1935 for raw in &urls[1..] {
1936 let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1937 if (parsed.host_str()?, parsed.port_or_known_default()?)
1938 != (authority.0.as_str(), authority.1)
1939 {
1940 return None;
1941 }
1942 }
1943 pinned_public_agent_pooled(
1944 &first,
1945 allow_private,
1946 "object-store URL",
1947 AgentShape {
1948 idle_per_host: V2_UPLOAD_CONCURRENCY,
1949 ..AgentShape::default()
1950 },
1951 )
1952 .ok()
1953}
1954
1955fn object_store_transport_error(error: ureq::Transport) -> LinkError {
1961 LinkError::Transport {
1962 hub: "the object store".to_string(),
1963 message: format!("network error ({:?})", error.kind()),
1964 }
1965}
1966
1967fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1968 let http = presigned_agent(cfg, raw)?;
1969 let deadline = std::time::Instant::now()
1970 .checked_add(std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS))
1971 .ok_or_else(upload_deadline_error)?;
1972 let mut attempt = 0;
1973 let result = loop {
1974 let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
1978 if let Some(map) = headers.as_object() {
1979 for (name, value) in map {
1980 if let Some(value) = value.as_str() {
1981 req = req.set(name, value);
1982 }
1983 }
1984 }
1985 match req.send_bytes(bytes) {
1986 Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
1992 attempt += 1;
1993 }
1994 Err(ureq::Error::Status(status, _))
1995 if status != 412
1996 && is_retryable_upload_status(status)
1997 && wait_for_upload_retry(deadline, attempt) =>
1998 {
1999 attempt += 1;
2000 }
2001 result => break result,
2002 }
2003 };
2004 match result {
2005 Ok(resp) if (200..300).contains(&resp.status()) => {
2006 drain_presigned_response(resp);
2007 Ok(())
2008 }
2009 Ok(resp) => Err(presigned_upload_refusal(resp)),
2010 Err(error) => match error {
2011 ureq::Error::Status(412, _) => Ok(()),
2016 ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
2017 ureq::Error::Transport(err) => Err(object_store_transport_error(err)),
2018 },
2019 }
2020}
2021
2022fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
2031 let mut buf = Vec::new();
2032 response
2033 .into_reader()
2034 .take(limit)
2035 .read_to_end(&mut buf)
2036 .map_err(|error| LinkError::Transport {
2037 hub: peer.to_string(),
2038 message: error.to_string(),
2039 })?;
2040 Ok(buf)
2041}
2042
2043fn drain_presigned_response(response: ureq::Response) {
2048 let mut reader = response.into_reader().take(64 * 1024);
2049 let _ = std::io::copy(&mut reader, &mut std::io::sink());
2050}
2051
2052fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
2055 let status = response.status();
2056 let detail = response
2057 .into_string()
2058 .ok()
2059 .map(|body| body.chars().take(400).collect::<String>())
2060 .filter(|body| !body.trim().is_empty());
2061 LinkError::Http {
2062 what: "pack upload",
2063 status,
2064 message: match detail {
2065 Some(body) => format!(
2066 "object store rejected the upload: {}",
2067 body.replace('\n', " ")
2068 ),
2069 None => "object store rejected the upload".to_string(),
2070 },
2071 code: None,
2072 details: None,
2073 }
2074}
2075
2076fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
2077 max_bytes.checked_add(1)
2078}
2079
2080fn presigned_download_read_limit() -> u64 {
2081 one_past_bounded_limit(MAX_PACK_BYTES)
2082 .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
2083}
2084
2085fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
2086 let http = presigned_agent(cfg, raw)?;
2087 let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
2088 Ok(resp) => resp,
2089 Err(error) => match *error {
2090 ureq::Error::Status(_, resp) => {
2091 return Err(LinkError::Http {
2092 what: "pack download",
2093 status: resp.status(),
2094 message: "object store rejected the download".to_string(),
2095 code: None,
2096 details: None,
2097 });
2098 }
2099 ureq::Error::Transport(err) => {
2100 return Err(LinkError::Transport {
2101 hub: "the object store".to_string(),
2102 message: err.to_string(),
2103 });
2104 }
2105 },
2106 };
2107 if !(200..300).contains(&resp.status()) {
2108 return Err(LinkError::Http {
2109 what: "pack download",
2110 status: resp.status(),
2111 message: "object store rejected the download".to_string(),
2112 code: None,
2113 details: None,
2114 });
2115 }
2116 let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
2117 if bytes.len() as u64 > MAX_PACK_BYTES {
2118 return Err(LinkError::InvalidPack {
2119 message: "download exceeds the compressed-size limit".to_string(),
2120 });
2121 }
2122 Ok(bytes)
2123}
2124
2125fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
2129 if !(200..300).contains(&r.status) {
2130 let message = r
2131 .body
2132 .as_ref()
2133 .and_then(|b| b.get("error"))
2134 .and_then(Value::as_str)
2135 .unwrap_or("unknown error")
2136 .to_string();
2137 let code = r
2138 .body
2139 .as_ref()
2140 .and_then(|b| b.get("code"))
2141 .and_then(Value::as_str)
2142 .map(str::to_string);
2143 let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
2144 return Err(LinkError::Http {
2145 what,
2146 status: r.status,
2147 message,
2148 code,
2149 details,
2150 });
2151 }
2152 r.body.ok_or(LinkError::NotJson {
2153 what,
2154 status: r.status,
2155 })
2156}
2157
2158fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2167 match ip {
2168 std::net::IpAddr::V4(ip) => {
2169 let [a, b, c, _] = ip.octets();
2170 !(a == 0
2171 || a == 10
2172 || a == 127
2173 || (a == 100 && (64..=127).contains(&b))
2174 || (a == 169 && b == 254)
2175 || (a == 172 && (16..=31).contains(&b))
2176 || (a == 192 && b == 0 && c == 0)
2177 || (a == 192 && b == 0 && c == 2)
2178 || (a == 192 && b == 88 && c == 99)
2179 || (a == 192 && b == 168)
2180 || (a == 198 && (b == 18 || b == 19))
2181 || (a == 198 && b == 51 && c == 100)
2182 || (a == 203 && b == 0 && c == 113)
2183 || a >= 224)
2184 }
2185 std::net::IpAddr::V6(ip) => {
2186 let segments = ip.segments();
2187 (segments[0] & 0xe000) == 0x2000
2192 && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2193 && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2194 && segments[0] != 0x2002
2195 && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2196 }
2197 }
2198}
2199
2200#[derive(Clone)]
2201struct PinnedRegistryResolver {
2202 netloc: String,
2203 addresses: Vec<std::net::SocketAddr>,
2204}
2205
2206impl ureq::Resolver for PinnedRegistryResolver {
2207 fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2208 if requested == self.netloc {
2209 Ok(self.addresses.clone())
2210 } else {
2211 Err(std::io::Error::new(
2212 std::io::ErrorKind::PermissionDenied,
2213 "registry request attempted to resolve an unvalidated authority",
2214 ))
2215 }
2216 }
2217}
2218
2219fn pinned_public_agent(
2220 url: &url::Url,
2221 allow_private: bool,
2222 label: &str,
2223) -> LinkResult<ureq::Agent> {
2224 pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2225}
2226
2227struct AgentShape {
2232 idle_per_host: usize,
2233 overall: std::time::Duration,
2234}
2235
2236impl Default for AgentShape {
2237 fn default() -> Self {
2238 Self {
2239 idle_per_host: 1,
2240 overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2241 }
2242 }
2243}
2244
2245fn pinned_public_agent_pooled(
2246 url: &url::Url,
2247 allow_private: bool,
2248 label: &str,
2249 shape: AgentShape,
2250) -> LinkResult<ureq::Agent> {
2251 let host = url
2252 .host_str()
2253 .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2254 let port = url
2255 .port_or_known_default()
2256 .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2257 let addresses = resolve_addresses_with_deadline(
2258 host,
2259 port,
2260 std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2261 )
2262 .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2263 if addresses.is_empty() {
2264 return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2265 }
2266 if !allow_private
2267 && addresses
2268 .iter()
2269 .any(|address| !is_public_registry_ip(address.ip()))
2270 {
2271 return Err(invalid_feed(format!(
2272 "{label} resolves to a non-public address"
2273 )));
2274 }
2275 let netloc = if host.contains(':') {
2276 format!("[{host}]:{port}")
2277 } else {
2278 format!("{host}:{port}")
2279 };
2280 Ok(agent_builder_with_timeout(shape.overall)
2281 .max_idle_connections_per_host(shape.idle_per_host.max(1))
2282 .resolver(PinnedRegistryResolver { netloc, addresses })
2283 .build())
2284}
2285
2286fn resolve_addresses_with_deadline(
2291 host: &str,
2292 port: u16,
2293 timeout: std::time::Duration,
2294) -> std::io::Result<Vec<std::net::SocketAddr>> {
2295 use std::net::ToSocketAddrs as _;
2296
2297 let host = host.to_string();
2298 let (send, receive) = std::sync::mpsc::sync_channel(1);
2299 std::thread::Builder::new()
2300 .name("dbmd-dns".to_string())
2301 .spawn(move || {
2302 let result = (host.as_str(), port)
2303 .to_socket_addrs()
2304 .map(|addresses| addresses.collect());
2305 let _ = send.send(result);
2306 })
2307 .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2308 match receive.recv_timeout(timeout) {
2309 Ok(result) => result,
2310 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2311 std::io::ErrorKind::TimedOut,
2312 "resolution exceeded its deadline",
2313 )),
2314 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2315 "resolver stopped without returning a result",
2316 )),
2317 }
2318}
2319
2320fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2321 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2322 pinned_public_agent(url, allow_private, "registry home")
2323}
2324
2325fn get_json_absolute(url: &str) -> LinkResult<Value> {
2330 let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2331 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2332 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2333 || !parsed.username().is_empty()
2334 || parsed.password().is_some()
2335 || parsed.query().is_some()
2336 || parsed.fragment().is_some()
2337 {
2338 return Err(invalid_feed("unsafe registry home URL"));
2339 }
2340 let http = registry_agent(&parsed)?;
2341 let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2342 Ok(resp) => resp,
2343 Err(error) => match *error {
2344 ureq::Error::Status(status, resp) => {
2345 let _ = resp;
2346 return Err(LinkError::Http {
2347 what: "registry home fetch",
2348 status,
2349 message: "the home node rejected the card request".to_string(),
2350 code: None,
2351 details: None,
2352 });
2353 }
2354 ureq::Error::Transport(err) => {
2355 return Err(LinkError::Transport {
2356 hub: url.to_string(),
2357 message: err.to_string(),
2358 });
2359 }
2360 },
2361 };
2362 if !(200..300).contains(&resp.status()) {
2363 return Err(LinkError::Http {
2364 what: "registry home fetch",
2365 status: resp.status(),
2366 message: "the home node returned a redirect or error".to_string(),
2367 code: None,
2368 details: None,
2369 });
2370 }
2371 let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2372 if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2373 return Err(LinkError::ResponseTooLarge {
2374 limit_bytes: MAX_REGISTRY_CARD_BYTES,
2375 });
2376 }
2377 serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2378 message: "the home node returned invalid JSON".to_string(),
2379 })
2380}
2381
2382pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2389 require_safe_ref(handle)?;
2390 let trust_directory = open_trust_dir(cfg)?;
2394 let reg = request_capped(
2395 cfg,
2396 "GET",
2397 &format!("/api/hub/registry/{handle}"),
2398 None,
2399 Auth::None,
2400 MAX_REGISTRY_CARD_BYTES,
2401 )?;
2402 if reg.status == 404 {
2403 return Ok(None);
2404 }
2405 let body = ensure_ok(reg, "registry resolve")?;
2406 let home = body
2407 .get("home")
2408 .and_then(Value::as_str)
2409 .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2410 let brain = body
2411 .get("brain")
2412 .and_then(Value::as_str)
2413 .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2414 if !crate::ulid::is_ulid(brain) {
2415 return Err(invalid_feed(
2416 "registry entry brain is not a canonical lowercase ULID",
2417 ));
2418 }
2419 let want_fp = body
2420 .get("identity")
2421 .and_then(|i| i.get("fingerprint"))
2422 .and_then(Value::as_str)
2423 .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2424
2425 let home = home.trim_end_matches('/');
2426 let origin = normalized_origin(home)?;
2427 if origin != home {
2428 return Err(invalid_feed(
2429 "registry home must be an origin without a path, query, or fragment",
2430 ));
2431 }
2432 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2433 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2434 if let Some(binding) = &alias_binding {
2435 if binding
2436 .home
2437 .as_deref()
2438 .is_some_and(|pinned_home| pinned_home != home)
2439 {
2440 return Err(invalid_feed(
2441 "registry relocated a pinned handle to a different home",
2442 ));
2443 }
2444 }
2445 let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2446 if card.get("id").and_then(Value::as_str) != Some(brain) {
2447 return Err(invalid_feed(
2448 "the home node served a card for a different brain",
2449 ));
2450 }
2451 let identity: FeedIdentity = serde_json::from_value(
2452 card.get("identity")
2453 .cloned()
2454 .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2455 )
2456 .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2457 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2458 let got_fp = card
2459 .get("identity")
2460 .and_then(|i| i.get("fingerprint"))
2461 .and_then(Value::as_str)
2462 .unwrap_or_default();
2463 if got_fp != want_fp {
2464 return Err(invalid_feed(
2465 "the home node served an identity that does not match the registry — refusing",
2466 ));
2467 }
2468 let current = format!("ed25519:{}", identity.fingerprint);
2469 let advertised_seq = card
2470 .get("headSeq")
2471 .and_then(Value::as_u64)
2472 .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2473 let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2474 if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2475 || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2476 {
2477 return Err(invalid_feed(
2478 "the home node served an invalid feed head boundary",
2479 ));
2480 }
2481 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2485 let registry_alias = AliasBinding {
2486 v: 1,
2487 origin: normalized_origin(&cfg.hub)?,
2488 requested: handle.to_string(),
2489 brain: brain.to_string(),
2490 home: Some(home.to_string()),
2491 };
2492 save_canonical_pin_and_alias(
2493 cfg,
2494 &trust_directory,
2495 handle,
2496 brain,
2497 TrustState {
2498 v: 2,
2499 origin: normalized_origin(&cfg.hub)?,
2500 requested: brain.to_string(),
2501 brain: brain.to_string(),
2502 home: None,
2503 anchor,
2504 current,
2505 head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2506 feed_hash: pinned
2507 .as_ref()
2508 .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2509 rotations: identity.rotations.clone(),
2510 hub_signer: None,
2511 protocol_profile: None,
2512 },
2513 Some(®istry_alias),
2514 )?;
2515 let mut out = card;
2516 if let Value::Object(map) = &mut out {
2517 map.insert("home".to_string(), Value::String(home.to_string()));
2518 map.insert(
2519 "resolvedVia".to_string(),
2520 Value::String("registry".to_string()),
2521 );
2522 }
2523 Ok(Some(out))
2524}
2525
2526pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2527 require_safe_ref(&addr.brain)?;
2531 if let Some(target) = &addr.target {
2532 let (given, ok) = match target {
2533 AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2534 AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2535 };
2536 if !ok {
2537 return Err(LinkError::BadAddress {
2538 given: given.clone(),
2539 reason: BAD_TARGET_REASON.to_string(),
2540 });
2541 }
2542 }
2543
2544 if let Some(target) = &addr.target {
2550 if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2551 let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2552 what: "resolve",
2553 status: 404,
2554 message: "record not found".to_string(),
2555 code: Some("NOT_FOUND".to_string()),
2556 details: None,
2557 })?;
2558 let (path, file) = match target {
2559 AddressTarget::Path(path) => {
2560 let file =
2561 v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2562 LinkError::Http {
2563 what: "resolve",
2564 status: 404,
2565 message: "record not found".to_string(),
2566 code: Some("NOT_FOUND".to_string()),
2567 details: None,
2568 }
2569 })?;
2570 (path.clone(), file)
2571 }
2572 AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2573 };
2574 let mut downloaded =
2575 download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2576 let (_, bytes) = downloaded
2577 .pop()
2578 .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2579 let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2580 accept_v2_head(cfg, &head)?;
2581 return Ok(resolved);
2582 }
2583 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2584 if !remote.head.verified {
2585 return Err(invalid_feed(
2586 "a path-scoped feed cannot prove a record against the full signed snapshot",
2587 ));
2588 }
2589 if remote.head.seq == 0 {
2590 return Err(LinkError::Http {
2591 what: "resolve",
2592 status: 404,
2593 message: "record not found".to_string(),
2594 code: Some("NOT_FOUND".to_string()),
2595 details: None,
2596 });
2597 }
2598 let brain = remote.head.brain.clone();
2599 let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2600 return resolve_from_verified_pack(&brain, target, pack);
2601 }
2602
2603 let path = format!("/api/hub/brains/{}", addr.brain);
2604 let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2609 if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2610 if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2611 return Ok(card);
2612 }
2613 }
2614 let mut resolved = ensure_ok(direct, "resolve")?;
2615 if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2616 let v2 = v2_verified_head(cfg, &addr.brain)?
2617 .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2618 if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2619 return Err(invalid_feed(
2620 "resolve card is not bound to the verified v2 brain",
2621 ));
2622 }
2623 let card_identity: FeedIdentity = serde_json::from_value(
2624 resolved
2625 .get("identity")
2626 .cloned()
2627 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2628 )
2629 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2630 if card_identity != v2_identity(&v2.identity) {
2631 return Err(invalid_feed(
2632 "resolve card identity differs from the verified v2 identity",
2633 ));
2634 }
2635 accept_v2_head(cfg, &v2)?;
2636 if let Value::Object(card) = &mut resolved {
2637 card.insert(
2638 "headSeq".to_string(),
2639 json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2640 );
2641 card.insert(
2642 "feedHash".to_string(),
2643 v2.pointer
2644 .as_ref()
2645 .map(|pointer| Value::String(pointer.feed_hash.clone()))
2646 .unwrap_or(Value::Null),
2647 );
2648 card.insert(
2649 "storageProfile".to_string(),
2650 Value::String("v2".to_string()),
2651 );
2652 if let Some(pointer) = &v2.pointer {
2653 card.insert(
2654 "updatedAt".to_string(),
2655 Value::String(pointer.signed_at.clone()),
2656 );
2657 }
2658 }
2659 return Ok(resolved);
2660 }
2661 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2665 if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2666 || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2667 || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2668 {
2669 return Err(invalid_feed(
2670 "resolve card is not bound to the exact verified feed checkpoint",
2671 ));
2672 }
2673 let card_identity: FeedIdentity = serde_json::from_value(
2674 resolved
2675 .get("identity")
2676 .cloned()
2677 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2678 )
2679 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2680 if remote.identity.as_ref() != Some(&card_identity) {
2681 return Err(invalid_feed(
2682 "resolve card identity differs from the verified feed identity",
2683 ));
2684 }
2685 Ok(resolved)
2686}
2687
2688fn resolve_from_verified_pack(
2693 brain: &str,
2694 target: &AddressTarget,
2695 pack: Vec<u8>,
2696) -> LinkResult<Value> {
2697 let entries = parse_store_pack(pack)?;
2698 let mut matched: Option<(String, Vec<u8>)> = None;
2699
2700 for (path, bytes) in entries {
2701 let is_candidate = match target {
2702 AddressTarget::Path(want) => &path == want,
2703 AddressTarget::Id(_) => {
2704 path.ends_with(".md")
2705 && (path.starts_with("records/") || path.starts_with("sources/"))
2706 }
2707 };
2708 if !is_candidate {
2709 continue;
2710 }
2711 let text = std::str::from_utf8(&bytes)
2712 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2713 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2714 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2715 if let AddressTarget::Id(want) = target {
2716 let frontmatter =
2717 crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2718 .map_err(|_| {
2719 invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2720 })?;
2721 if frontmatter.id.as_deref() != Some(want) {
2722 continue;
2723 }
2724 }
2725 if matched.is_some() {
2726 return Err(invalid_feed(
2727 "signed snapshot contains more than one record for the requested target",
2728 ));
2729 }
2730 matched = Some((path, bytes));
2731 }
2732
2733 let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2734 what: "resolve",
2735 status: 404,
2736 message: "record not found".to_string(),
2737 code: Some("NOT_FOUND".to_string()),
2738 details: None,
2739 })?;
2740 resolve_from_verified_record_bytes(brain, target, path, bytes)
2741}
2742
2743fn resolve_from_verified_record_bytes(
2744 brain: &str,
2745 target: &AddressTarget,
2746 path: String,
2747 bytes: Vec<u8>,
2748) -> LinkResult<Value> {
2749 match target {
2750 AddressTarget::Path(expected) if expected != &path => {
2751 return Err(invalid_feed(
2752 "verified record path differs from the requested path",
2753 ));
2754 }
2755 AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2756 return Err(invalid_feed(
2757 "verified id resolved outside records or sources",
2758 ));
2759 }
2760 _ => {}
2761 }
2762 let text = std::str::from_utf8(&bytes)
2763 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2764 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2765 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2766 let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2767 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2768 let Value::Object(fields) = frontmatter else {
2769 return Err(invalid_feed(format!(
2770 "signed snapshot record `{path}` frontmatter is not a mapping"
2771 )));
2772 };
2773 if let AddressTarget::Id(expected) = target {
2774 if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2775 return Err(invalid_feed(
2776 "verified record id differs from the requested id",
2777 ));
2778 }
2779 }
2780 let mut document = serde_json::Map::new();
2781 document.insert("path".to_string(), Value::String(path));
2782 for (key, value) in fields {
2783 document.insert(key, value);
2784 }
2785 document.insert("body".to_string(), Value::String(parsed.body));
2786 document.insert(
2787 "contentSha".to_string(),
2788 Value::String(content_sha256(&bytes)),
2789 );
2790 Ok(json!({
2791 "brain": brain,
2792 "document": Value::Object(document),
2793 }))
2794}
2795
2796#[derive(Debug, Clone, serde::Serialize)]
2802pub struct PullReport {
2803 pub brain: String,
2805 pub slug: String,
2807 #[serde(rename = "headSeq")]
2809 pub head_seq: u64,
2810 pub files: usize,
2812 pub dest: String,
2814 #[serde(rename = "extraLocal")]
2817 pub extra_local: Vec<String>,
2818 #[serde(rename = "syncStatus")]
2820 pub sync_status: String,
2821}
2822
2823struct V2PulledSnapshot {
2824 report: PullReport,
2825 head: V2VerifiedHead,
2826 files: std::collections::BTreeMap<String, V2BaselineFile>,
2827 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2828 local: V2LocalView,
2829 local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2830}
2831
2832fn download_verified_snapshot_pack(
2833 cfg: &HubConfig,
2834 brain: &str,
2835 remote: &VerifiedRemote,
2836) -> LinkResult<Vec<u8>> {
2837 let feed_hash = remote
2838 .head
2839 .feed_hash
2840 .as_deref()
2841 .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2842 let signed_head = remote
2843 .head_entry
2844 .as_ref()
2845 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2846 let expected = &signed_head.entry.pack_sha256;
2847 if !is_sha256(expected) {
2848 return Err(invalid_feed(
2849 "signed head carries an invalid snapshot pack digest",
2850 ));
2851 }
2852 let path = format!(
2853 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2854 remote.head.seq
2855 );
2856 let body = ensure_ok(
2857 request(cfg, "GET", &path, None, Auth::Required)?,
2858 "sync pull",
2859 )?;
2860 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2861 || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2862 || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2863 || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2864 {
2865 return Err(invalid_feed(
2866 "export response is not bound to the exact verified snapshot",
2867 ));
2868 }
2869 let url = body
2870 .get("url")
2871 .and_then(Value::as_str)
2872 .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2873 let bytes = get_presigned(cfg, url)?;
2874 if content_sha256(&bytes) != *expected {
2875 return Err(LinkError::InvalidPack {
2876 message: "downloaded pack does not match the signed snapshot digest".to_string(),
2877 });
2878 }
2879 let entries = parse_store_pack(bytes.clone())?;
2880 if signed_head.entry.kind == "push" {
2881 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2882 }
2883 Ok(bytes)
2884}
2885
2886#[derive(Debug, Clone, Deserialize, Serialize)]
2887struct V2PointerBody {
2888 v: u8,
2889 brain: String,
2890 seq: u64,
2891 commit_hash: String,
2892 feed_hash: String,
2893 content_root: Option<String>,
2894 asset_root: Option<String>,
2895 materializer: String,
2896 signer_epoch: u64,
2897 control_revision: String,
2898 backup_preparation: String,
2899 prior_pointer_hash: Option<String>,
2900 signed_at: String,
2901}
2902
2903#[derive(Debug, Clone, Deserialize)]
2904struct V2SignedPointer {
2905 pointer: V2PointerBody,
2906 hub_public_key: String,
2907 hub_fingerprint: String,
2908 sig: String,
2909}
2910
2911#[derive(Debug, Clone, Deserialize)]
2912struct V2HeadIdentity {
2913 #[serde(default)]
2914 custody: String,
2915 fingerprint: String,
2916 public_key_spki: String,
2917 #[serde(default)]
2918 previous: Vec<V2PreviousIdentity>,
2919 #[serde(default)]
2920 rotations: Vec<String>,
2921}
2922
2923#[derive(Debug, Clone, Deserialize)]
2924struct V2PreviousIdentity {
2925 fingerprint: String,
2926 public_key_spki: String,
2927}
2928
2929#[derive(Debug, Deserialize)]
2930struct V2HeadResponse {
2931 v: u8,
2932 brain_id: String,
2933 profile: String,
2934 view: Option<V2HeadView>,
2935 pointer: Option<V2SignedPointer>,
2936 identity: Option<V2HeadIdentity>,
2937}
2938
2939#[derive(Debug, Clone, Deserialize)]
2940struct V2HeadView {
2941 kind: String,
2942 #[serde(default)]
2943 id: Option<String>,
2944 control_revision: String,
2945}
2946
2947#[derive(Debug, Clone)]
2948struct V2VerifiedHead {
2949 requested: String,
2950 brain_id: String,
2951 view_kind: String,
2952 view_revision: String,
2954 control_revision: String,
2956 identity: V2HeadIdentity,
2957 pointer: Option<V2PointerBody>,
2958 trust: TrustState,
2959 alias: Option<AliasBinding>,
2960}
2961
2962fn verify_v2_spki_signature(
2963 public_key: &str,
2964 message: &[u8],
2965 signature: &str,
2966) -> LinkResult<Vec<u8>> {
2967 let der = URL_SAFE_NO_PAD
2968 .decode(public_key)
2969 .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2970 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2971 return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2972 }
2973 let sig = URL_SAFE_NO_PAD
2974 .decode(signature)
2975 .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2976 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2977 .verify(message, &sig)
2978 .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2979 Ok(der)
2980}
2981
2982fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2983 if pointer.pointer.v != 2
2984 || pointer.pointer.brain != expected_brain
2985 || pointer.pointer.seq == 0
2986 || !is_sha256(&pointer.pointer.commit_hash)
2987 || !is_sha256(&pointer.pointer.feed_hash)
2988 || pointer
2989 .pointer
2990 .content_root
2991 .as_deref()
2992 .is_some_and(|hash| !is_sha256(hash))
2993 || !is_sha256(&pointer.pointer.backup_preparation)
2994 {
2995 return Err(invalid_feed("v2 pointer fields are invalid"));
2996 }
2997 let value = serde_json::to_value(&pointer.pointer)
2998 .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2999 let message = crate::linkmd_v2::canonical_bytes(&value)
3000 .map_err(|error| invalid_feed(error.to_string()))?;
3001 let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
3002 let fingerprint = format!("{:x}", Sha256::digest(&der));
3003 if fingerprint != pointer.hub_fingerprint {
3004 return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
3005 }
3006 Ok(format!(
3007 "{}:{}",
3008 pointer.hub_fingerprint, pointer.hub_public_key
3009 ))
3010}
3011
3012fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
3013 FeedIdentity {
3014 fingerprint: identity.fingerprint.clone(),
3015 public_key_spki: identity.public_key_spki.clone(),
3016 previous: identity
3017 .previous
3018 .iter()
3019 .map(|previous| PreviousIdentity {
3020 fingerprint: previous.fingerprint.clone(),
3021 public_key_spki: previous.public_key_spki.clone(),
3022 })
3023 .collect(),
3024 rotations: identity.rotations.clone(),
3025 }
3026}
3027
3028fn verified_v2_commit_object(
3029 raw: &[u8],
3030 identity: &V2HeadIdentity,
3031) -> LinkResult<serde_json::Map<String, Value>> {
3032 let mut value: Value =
3033 serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
3034 let canonical = crate::linkmd_v2::canonical_bytes(&value)
3035 .map_err(|error| invalid_feed(error.to_string()))?;
3036 if canonical != raw {
3037 return Err(invalid_feed("v2 commit is not canonical JSON"));
3038 }
3039 let object = value
3040 .as_object_mut()
3041 .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
3042 let sig = object
3043 .remove("sig")
3044 .and_then(|value| value.as_str().map(str::to_string))
3045 .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
3046 const FIELDS: [&str; 18] = [
3047 "actor_ref",
3048 "asset_root",
3049 "brain",
3050 "changes_sha256",
3051 "control_revision",
3052 "materializer",
3053 "op",
3054 "parent_asset_root",
3055 "parent_commit",
3056 "parent_root",
3057 "prev_entry_hash",
3058 "public_key",
3059 "seq",
3060 "signer_epoch",
3061 "state_root",
3062 "ts",
3063 "v",
3064 "v1_bridge",
3065 ];
3066 if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
3067 return Err(invalid_feed("v2 commit has a non-normative field set"));
3068 }
3069 let seq = object
3070 .get("seq")
3071 .and_then(Value::as_u64)
3072 .filter(|seq| *seq > 0)
3073 .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
3074 let signer_epoch = object
3075 .get("signer_epoch")
3076 .and_then(Value::as_u64)
3077 .filter(|epoch| *epoch > 0)
3078 .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
3079 let hash_or_null = |field: &str| {
3080 object
3081 .get(field)
3082 .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
3083 };
3084 if object.get("v").and_then(Value::as_u64) != Some(2)
3085 || object.get("op").and_then(Value::as_str) != Some("changeset")
3086 || !object
3087 .get("changes_sha256")
3088 .and_then(Value::as_str)
3089 .is_some_and(is_sha256)
3090 || !object
3091 .get("actor_ref")
3092 .and_then(Value::as_str)
3093 .is_some_and(is_sha256)
3094 || !object
3095 .get("control_revision")
3096 .and_then(Value::as_str)
3097 .is_some_and(is_sha256)
3098 || !object
3099 .get("state_root")
3100 .and_then(Value::as_str)
3101 .is_some_and(is_sha256)
3102 || !hash_or_null("parent_commit")
3103 || !hash_or_null("parent_root")
3104 || !hash_or_null("parent_asset_root")
3105 || !hash_or_null("asset_root")
3106 || !hash_or_null("prev_entry_hash")
3107 || !object
3108 .get("materializer")
3109 .and_then(Value::as_str)
3110 .is_some_and(|value| !value.is_empty() && value.len() <= 128)
3111 || !object
3112 .get("ts")
3113 .and_then(Value::as_str)
3114 .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
3115 {
3116 return Err(invalid_feed("v2 commit fields are invalid"));
3117 }
3118 if (seq == 1
3119 && [
3120 "parent_commit",
3121 "parent_root",
3122 "parent_asset_root",
3123 "prev_entry_hash",
3124 ]
3125 .iter()
3126 .any(|field| !object.get(*field).is_some_and(Value::is_null)))
3127 || (seq > 1
3128 && ["parent_commit", "parent_root", "prev_entry_hash"]
3129 .iter()
3130 .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
3131 {
3132 return Err(invalid_feed("v2 commit parent shape is invalid"));
3133 }
3134 match object.get("v1_bridge") {
3135 Some(Value::Null) => {}
3136 Some(Value::Object(bridge))
3137 if seq == 1
3138 && bridge.len() == 3
3139 && bridge
3140 .get("head_seq")
3141 .and_then(Value::as_u64)
3142 .is_some_and(|v| v > 0)
3143 && bridge
3144 .get("feed_hash")
3145 .and_then(Value::as_str)
3146 .is_some_and(is_sha256)
3147 && bridge
3148 .get("pack_sha256")
3149 .and_then(Value::as_str)
3150 .is_some_and(is_sha256) => {}
3151 _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
3152 }
3153 let public_key = object
3154 .get("public_key")
3155 .and_then(Value::as_str)
3156 .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3157 let der = URL_SAFE_NO_PAD
3158 .decode(public_key)
3159 .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3160 let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3161 if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3162 return Err(invalid_feed("v2 commit brain identity mismatch"));
3163 }
3164 verify_identity_chain(&v2_identity(identity), None)?;
3166 let mut chain: Vec<(&str, &str)> = identity
3169 .previous
3170 .iter()
3171 .rev()
3172 .map(|previous| {
3173 (
3174 previous.fingerprint.as_str(),
3175 previous.public_key_spki.as_str(),
3176 )
3177 })
3178 .collect();
3179 chain.push((&identity.fingerprint, &identity.public_key_spki));
3180 let signer_index = chain.iter().position(|(fingerprint, spki)| {
3181 *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3182 });
3183 let Some(signer_index) = signer_index else {
3184 return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3185 };
3186 if signer_epoch != signer_index as u64 + 1 {
3187 return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3188 }
3189 let lower_boundary = if signer_index == 0 {
3190 None
3191 } else {
3192 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3193 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3194 Some(prior.prior_head_seq)
3195 };
3196 let upper_boundary = if signer_index == identity.rotations.len() {
3197 None
3198 } else {
3199 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3200 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3201 Some(next.prior_head_seq)
3202 };
3203 if lower_boundary.is_some_and(|boundary| seq <= boundary)
3204 || upper_boundary.is_some_and(|boundary| seq > boundary)
3205 {
3206 return Err(invalid_feed(
3207 "v2 commit signer is outside its authenticated rotation epoch",
3208 ));
3209 }
3210 let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3211 .map_err(|error| invalid_feed(error.to_string()))?;
3212 verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3213 Ok(object.clone())
3214}
3215
3216#[derive(Debug, Deserialize)]
3217struct V2FeedWireEntry {
3218 seq: u64,
3219 commit_hash: String,
3220 feed_hash: String,
3221 bytes_base64: String,
3222}
3223
3224#[derive(Debug, Deserialize)]
3225struct V2FeedPage {
3226 v: u8,
3227 head_seq: u64,
3228 head_commit_hash: String,
3229 head_feed_hash: String,
3230 entries: Vec<V2FeedWireEntry>,
3231 next_after: u64,
3232 complete: bool,
3233}
3234
3235fn replay_v2_feed(
3236 cfg: &HubConfig,
3237 brain: &str,
3238 pointer: &V2PointerBody,
3239 identity: &V2HeadIdentity,
3240 start_after: u64,
3241 start_feed: Option<String>,
3242) -> LinkResult<()> {
3243 let mut after = start_after;
3244 let mut prior_feed = start_feed;
3245 let mut final_object = None;
3246 let mut replayed_entries = 0_u64;
3247 let mut replayed_bytes = 0_u64;
3248 while after < pointer.seq {
3249 let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3250 let value = ensure_ok(
3251 request_capped(
3252 cfg,
3253 "GET",
3254 &path,
3255 None,
3256 Auth::Required,
3257 MAX_FEED_REPLAY_BYTES,
3258 )?,
3259 "v2 feed replay",
3260 )?;
3261 let page: V2FeedPage = serde_json::from_value(value)
3262 .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3263 if page.v != 2
3264 || page.head_seq != pointer.seq
3265 || page.head_commit_hash != pointer.commit_hash
3266 || page.head_feed_hash != pointer.feed_hash
3267 || page.entries.is_empty()
3268 || page.entries.len() > FEED_PAGE_LIMIT
3269 {
3270 return Err(invalid_feed("v2 feed page differs from the signed head"));
3271 }
3272 for entry in page.entries {
3273 if entry.seq != after + 1
3274 || !is_sha256(&entry.commit_hash)
3275 || !is_sha256(&entry.feed_hash)
3276 {
3277 return Err(invalid_feed("v2 feed sequence is not contiguous"));
3278 }
3279 let raw = base64::engine::general_purpose::STANDARD
3280 .decode(&entry.bytes_base64)
3281 .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3282 replayed_entries = replayed_entries
3283 .checked_add(1)
3284 .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3285 replayed_bytes = replayed_bytes
3286 .checked_add(raw.len() as u64)
3287 .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3288 if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3289 {
3290 return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3291 }
3292 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3293 .map_err(|error| invalid_feed(error.to_string()))?
3294 != entry.commit_hash
3295 || content_sha256(&raw) != entry.feed_hash
3296 {
3297 return Err(invalid_feed("v2 feed entry address mismatch"));
3298 }
3299 let object = verified_v2_commit_object(&raw, identity)?;
3300 if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3301 || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3302 {
3303 return Err(invalid_feed(
3304 "v2 feed entry does not extend its predecessor",
3305 ));
3306 }
3307 after = entry.seq;
3308 prior_feed = Some(entry.feed_hash);
3309 final_object = Some((entry.commit_hash, object));
3310 }
3311 if page.next_after != after || (page.complete != (after == pointer.seq)) {
3312 return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3313 }
3314 }
3315 let (final_hash, object) =
3316 final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3317 if final_hash != pointer.commit_hash
3318 || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3319 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3320 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3321 || object.get("control_revision").and_then(Value::as_str)
3322 != Some(pointer.control_revision.as_str())
3323 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3324 {
3325 return Err(invalid_feed(
3326 "v2 replay did not converge on the signed pointer",
3327 ));
3328 }
3329 Ok(())
3330}
3331
3332fn verify_v1_to_v2_bridge(
3333 cfg: &HubConfig,
3334 brain: &str,
3335 pointer: &V2PointerBody,
3336 identity: &V2HeadIdentity,
3337 checkpoint: &TrustState,
3338) -> LinkResult<()> {
3339 let value = ensure_ok(
3340 request_capped(
3341 cfg,
3342 "GET",
3343 &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3344 None,
3345 Auth::Required,
3346 MAX_FEED_RESPONSE_BYTES,
3347 )?,
3348 "v2 genesis bridge",
3349 )?;
3350 let page: V2FeedPage = serde_json::from_value(value)
3351 .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3352 if page.v != 2
3353 || page.head_seq != pointer.seq
3354 || page.head_commit_hash != pointer.commit_hash
3355 || page.head_feed_hash != pointer.feed_hash
3356 || page.entries.len() != 1
3357 || page.entries[0].seq != 1
3358 || !is_sha256(&page.entries[0].commit_hash)
3359 || !is_sha256(&page.entries[0].feed_hash)
3360 {
3361 return Err(invalid_feed(
3362 "v2 genesis bridge page differs from the signed head",
3363 ));
3364 }
3365 let first = &page.entries[0];
3366 let raw = STANDARD
3367 .decode(&first.bytes_base64)
3368 .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3369 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3370 .map_err(|error| invalid_feed(error.to_string()))?
3371 != first.commit_hash
3372 || content_sha256(&raw) != first.feed_hash
3373 {
3374 return Err(invalid_feed("v2 genesis bridge address mismatch"));
3375 }
3376 let object = verified_v2_commit_object(&raw, identity)?;
3377 if checkpoint.head_seq == 0 {
3378 if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3379 return Err(invalid_feed(
3380 "empty v1 checkpoint did not transition through an empty v2 genesis",
3381 ));
3382 }
3383 return Ok(());
3384 }
3385 let bridge = object
3386 .get("v1_bridge")
3387 .and_then(Value::as_object)
3388 .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3389 let checkpoint_feed = checkpoint
3390 .feed_hash
3391 .as_deref()
3392 .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3393 if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3394 || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3395 {
3396 return Err(invalid_feed(
3397 "v2 genesis bridge differs from the pinned v1 checkpoint",
3398 ));
3399 }
3400 let legacy_raw = ensure_raw_ok(
3401 request_raw(
3402 cfg,
3403 "GET",
3404 &format!(
3405 "/api/hub/brains/{brain}/feed?after={}&limit=1",
3406 checkpoint.head_seq - 1
3407 ),
3408 None,
3409 Auth::Required,
3410 MAX_FEED_RESPONSE_BYTES,
3411 )?,
3412 "v1 bridge boundary",
3413 )?;
3414 let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3415 .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3416 let legacy_identity = legacy
3417 .identity
3418 .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3419 let item = legacy
3420 .entries
3421 .first()
3422 .filter(|_| legacy.entries.len() == 1)
3423 .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3424 if legacy.scope_limited
3425 || legacy.head_seq != checkpoint.head_seq
3426 || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3427 || item.entry.seq != checkpoint.head_seq
3428 || item.hash != checkpoint_feed
3429 || legacy_identity != v2_identity(identity)
3430 || bridge.get("pack_sha256").and_then(Value::as_str)
3431 != Some(item.entry.pack_sha256.as_str())
3432 {
3433 return Err(invalid_feed(
3434 "v1 bridge boundary differs from its signed legacy head",
3435 ));
3436 }
3437 let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3438 if anchor != checkpoint.anchor {
3439 return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3440 }
3441 verify_feed_item(item, &legacy_identity)?;
3442 verify_rotation_feed_boundaries(
3443 &legacy_identity,
3444 Some(checkpoint),
3445 std::slice::from_ref(item),
3446 checkpoint.head_seq,
3447 )?;
3448 Ok(())
3449}
3450
3451fn verify_v2_commit(
3452 cfg: &HubConfig,
3453 brain: &str,
3454 pointer: &V2PointerBody,
3455 identity: &V2HeadIdentity,
3456 pinned: Option<&TrustState>,
3457) -> LinkResult<()> {
3458 let path = format!(
3459 "/api/hub/brains/{brain}/v2/commit?commit={}",
3460 pointer.commit_hash
3461 );
3462 let raw = ensure_raw_ok(
3463 request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3464 "v2 commit",
3465 )?;
3466 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3467 .map_err(|error| invalid_feed(error.to_string()))?
3468 != pointer.commit_hash
3469 || content_sha256(&raw) != pointer.feed_hash
3470 {
3471 return Err(invalid_feed("v2 commit address differs from the pointer"));
3472 }
3473 let object = verified_v2_commit_object(&raw, identity)?;
3474 if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3475 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3476 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3477 || object.get("control_revision").and_then(Value::as_str)
3478 != Some(pointer.control_revision.as_str())
3479 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3480 {
3481 return Err(invalid_feed("v2 commit fields differ from the pointer"));
3482 }
3483 if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3484 if pointer.seq == checkpoint.head_seq + 1
3485 && object.get("prev_entry_hash").and_then(Value::as_str)
3486 != checkpoint.feed_hash.as_deref()
3487 {
3488 return Err(invalid_feed(
3489 "v2 commit does not extend the pinned feed hash",
3490 ));
3491 }
3492 if pointer.seq > checkpoint.head_seq + 1 {
3493 return replay_v2_feed(
3494 cfg,
3495 brain,
3496 pointer,
3497 identity,
3498 checkpoint.head_seq,
3499 checkpoint.feed_hash.clone(),
3500 );
3501 }
3502 } else {
3503 if let Some(checkpoint) = pinned {
3504 verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3505 }
3506 if pointer.seq > 1 {
3507 return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3508 }
3509 }
3510 Ok(())
3511}
3512
3513fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3514 require_hardened_filesystem("verified link.md v2 state")?;
3515 require_safe_ref(brain)?;
3516 let trust_directory = open_trust_dir(cfg)?;
3520 let path = format!("/api/hub/brains/{brain}/v2/head");
3521 let started = std::time::Instant::now();
3528 let mut wait = std::time::Duration::from_millis(250);
3529 let response = loop {
3530 let response = request(cfg, "GET", &path, None, Auth::Required)?;
3531 if !v2_validation_catching_up(&response) {
3532 break response;
3533 }
3534 if started.elapsed() >= std::time::Duration::from_secs(15 * 60) {
3535 return Err(LinkError::Http {
3536 what: "v2 head",
3537 status: response.status,
3538 message: "validation/index recovery did not reach the durable source head within 15 minutes"
3539 .to_string(),
3540 code: Some("validation_index_catching_up".to_string()),
3541 details: response.body,
3542 });
3543 }
3544 std::thread::sleep(wait);
3545 wait = wait
3546 .saturating_mul(2)
3547 .min(std::time::Duration::from_secs(5));
3548 };
3549 if response.status == 404 {
3550 if has_accepted_v2_ref(cfg, brain)? {
3551 return Err(LinkError::BrainUnavailable);
3552 }
3553 return Ok(None);
3554 }
3555 let body = ensure_ok(response, "v2 head")?;
3556 let head: V2HeadResponse = serde_json::from_value(body)
3557 .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3558 if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3559 return Err(invalid_feed("v2 head has no canonical brain id"));
3560 }
3561 if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3562 return Err(invalid_feed("v2 head resolved a different brain id"));
3563 }
3564 if head.profile == "v1" {
3565 return Ok(None);
3566 }
3567 if head.profile != "v2" && head.profile != "v2-empty" {
3568 return Err(invalid_feed("v2 head advertised an unknown profile"));
3569 }
3570 let view = head
3571 .view
3572 .as_ref()
3573 .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3574 if !matches!(view.kind.as_str(), "full" | "scoped")
3575 || !is_sha256(&view.control_revision)
3576 || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3577 {
3578 return Err(invalid_feed("v2 head has an invalid permission view"));
3579 }
3580 let view_kind = view.kind.clone();
3581 let view_revision = view
3584 .id
3585 .clone()
3586 .unwrap_or_else(|| view.control_revision.clone());
3587 let control_revision = view.control_revision.clone();
3588 let identity = head
3589 .identity
3590 .as_ref()
3591 .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3592 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3593 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3594 let feed_identity = v2_identity(identity);
3595 let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3596 let (seq, feed_hash, hub_signer) = match &head.pointer {
3597 None => {
3598 if head.profile != "v2-empty" {
3599 return Err(invalid_feed("initialized v2 head has no pointer"));
3600 }
3601 (
3602 0,
3603 None,
3604 pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3605 )
3606 }
3607 Some(signed) => {
3608 let signer = verify_v2_pointer(signed, &head.brain_id)?;
3609 if pinned
3610 .as_ref()
3611 .and_then(|state| state.hub_signer.as_ref())
3612 .is_some_and(|known| known != &signer)
3613 {
3614 return Err(invalid_feed(
3615 "v2 hub pointer signer changed without a trust transition",
3616 ));
3617 }
3618 if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3619 if signed.pointer.seq < checkpoint.head_seq
3620 || (signed.pointer.seq == checkpoint.head_seq
3621 && checkpoint.feed_hash.as_deref()
3622 != Some(signed.pointer.feed_hash.as_str()))
3623 {
3624 return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3625 }
3626 }
3627 verify_v2_commit(
3628 cfg,
3629 &head.brain_id,
3630 &signed.pointer,
3631 identity,
3632 pinned.as_ref(),
3633 )?;
3634 (
3635 signed.pointer.seq,
3636 Some(signed.pointer.feed_hash.clone()),
3637 Some(signer),
3638 )
3639 }
3640 };
3641 let trust = TrustState {
3642 v: 2,
3643 origin: normalized_origin(&cfg.hub)?,
3644 requested: head.brain_id.clone(),
3645 brain: head.brain_id.clone(),
3646 home: None,
3647 anchor,
3648 current: format!("ed25519:{}", identity.fingerprint),
3649 head_seq: seq,
3650 feed_hash,
3651 rotations: identity.rotations.clone(),
3652 hub_signer,
3653 protocol_profile: Some("link-v2".to_string()),
3654 };
3655 Ok(Some(V2VerifiedHead {
3656 requested: brain.to_string(),
3657 brain_id: head.brain_id,
3658 view_kind,
3659 view_revision,
3660 control_revision,
3661 identity: identity.clone(),
3662 pointer: head.pointer.map(|signed| signed.pointer),
3663 trust,
3664 alias: alias_binding,
3665 }))
3666}
3667
3668fn v2_validation_catching_up(response: &HubResponse) -> bool {
3669 response.status == 422
3670 && response.body.as_ref().is_some_and(|body| {
3671 body.get("code").and_then(Value::as_str) == Some("validation_index_catching_up")
3672 || body
3673 .get("details")
3674 .and_then(|details| details.get("code"))
3675 .and_then(Value::as_str)
3676 == Some("validation_index_catching_up")
3677 })
3678}
3679
3680fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3681 let directory = open_trust_dir(cfg)?;
3682 let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3683 let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3684 if let Some(current) = current {
3685 let common_invalid = head.trust.anchor != current.anchor
3686 || !head.trust.rotations.starts_with(¤t.rotations);
3687 let profile_invalid = if accepted_as_v2(¤t) {
3688 head.trust.head_seq < current.head_seq
3689 || (head.trust.head_seq == current.head_seq
3690 && head.trust.feed_hash != current.feed_hash)
3691 || current
3692 .hub_signer
3693 .as_ref()
3694 .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3695 } else {
3696 head.trust.protocol_profile.as_deref() != Some("link-v2")
3697 || head.trust.hub_signer.is_none()
3698 };
3699 if common_invalid || profile_invalid {
3700 return Err(invalid_feed(
3701 "v2 head cannot advance the currently accepted trust checkpoint",
3702 ));
3703 }
3704 }
3705 save_canonical_pin_and_alias(
3706 cfg,
3707 &directory,
3708 &head.requested,
3709 &head.brain_id,
3710 head.trust.clone(),
3711 alias.as_ref().or(head.alias.as_ref()),
3712 )
3713}
3714
3715#[derive(Debug, Clone, Deserialize, Serialize)]
3716struct V2BaselineFile {
3717 sha256: String,
3718 bytes: u64,
3719 #[serde(skip)]
3720 proof: Option<Vec<V2ProofStep>>,
3721}
3722
3723#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
3729struct V2ScanCacheFile {
3730 fingerprint: String,
3731 sha256: String,
3732 bytes: u64,
3733 #[serde(default)]
3734 withheld_targets: Vec<String>,
3735}
3736
3737#[derive(Debug, Clone, Deserialize, Serialize)]
3738struct V2SyncBaseline {
3739 v: u8,
3740 origin: String,
3741 brain: String,
3742 #[serde(default)]
3743 checkout_id: Option<String>,
3744 #[serde(default)]
3745 head_seq: Option<u64>,
3746 commit_hash: Option<String>,
3747 content_root: Option<String>,
3748 #[serde(default)]
3749 asset_root: Option<String>,
3750 #[serde(default)]
3751 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3752 #[serde(default)]
3753 view_kind: Option<String>,
3754 #[serde(default)]
3755 view_revision: Option<String>,
3756 #[serde(default)]
3760 control_revision: Option<String>,
3761 #[serde(default)]
3762 projection_sha256: Option<String>,
3763 files: std::collections::BTreeMap<String, V2BaselineFile>,
3764 #[serde(default)]
3765 scan_cache: std::collections::BTreeMap<String, V2ScanCacheFile>,
3766 #[serde(default)]
3767 local_policy_digest: Option<String>,
3768 #[serde(default)]
3769 local_eligibility: std::collections::BTreeMap<String, bool>,
3770 #[serde(default)]
3771 remote_copy_remains: std::collections::BTreeMap<String, String>,
3772}
3773
3774#[derive(Clone)]
3775struct V2LocalView {
3776 riding: std::collections::BTreeMap<String, (String, u64)>,
3777 scan_cache: std::collections::BTreeMap<String, V2ScanCacheFile>,
3778 eligibility: std::collections::BTreeMap<String, bool>,
3779 policy: crate::linkmd_sync_policy::SyncPolicy,
3780 withheld_links: Vec<V2WithheldLink>,
3781}
3782
3783#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3784struct V2WithheldLink {
3785 source: String,
3786 target: String,
3787}
3788
3789#[derive(Debug, Clone, Deserialize, Serialize)]
3790struct V2ProofStep {
3791 directory_root: String,
3792 component: String,
3793 proof: crate::linkmd_v2::HamtProof,
3794}
3795
3796#[derive(Debug, Deserialize)]
3797struct V2ManifestFile {
3798 path: String,
3799 sha256: String,
3800 bytes: u64,
3801 proof: Vec<V2ProofStep>,
3802}
3803
3804#[derive(Debug, Deserialize)]
3805struct V2ManifestPage {
3806 v: u8,
3807 commit: String,
3808 content_root: Option<String>,
3809 files: Vec<V2ManifestFile>,
3810 next_cursor: Option<String>,
3811}
3812
3813#[derive(Debug, Clone, Deserialize, Serialize)]
3814struct V2BaselineAsset {
3815 blob_sha256: String,
3816 bytes: u64,
3817 media_type: String,
3818 wrappers: Vec<String>,
3819 required: bool,
3820 disposition: String,
3821 leaf_hash: String,
3822}
3823
3824#[derive(Debug, Deserialize)]
3825struct V2AssetManifestItem {
3826 path: String,
3827 blob_sha256: String,
3828 bytes: u64,
3829 media_type: String,
3830 wrappers: Vec<String>,
3831 required: bool,
3832 disposition: String,
3833 leaf_hash: String,
3834 proof: crate::linkmd_v2::HamtProof,
3835}
3836
3837#[derive(Debug, Deserialize)]
3838struct V2AssetManifestPage {
3839 v: u8,
3840 commit: String,
3841 asset_root: Option<String>,
3842 assets: Vec<V2AssetManifestItem>,
3843 next_cursor: Option<String>,
3844}
3845
3846#[derive(Debug, Deserialize)]
3847struct V2SigningCandidate {
3848 seq: u64,
3849 content_root: Option<String>,
3850 asset_root: Option<String>,
3851 signing_bytes_base64: String,
3852 changes_base64: String,
3853 actor_claim_base64: String,
3854}
3855
3856#[derive(Debug, Deserialize)]
3857struct V2SigningCandidatePage {
3858 v: u8,
3859 challenge_id: String,
3860 mutation_id: String,
3861 request_hash: String,
3862 parent: V2SigningParent,
3863 candidate: V2SigningCandidate,
3864 files: Vec<V2ManifestFile>,
3865 #[serde(default)]
3866 assets: Vec<V2AssetManifestItem>,
3867 next_cursor: Option<String>,
3868 expires_at: String,
3869}
3870
3871#[derive(Debug, Deserialize)]
3872struct V2SigningParent {
3873 seq: u64,
3874 commit_hash: Option<String>,
3875}
3876
3877fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3878 let normalized = crate::linkmd_v2::normalize_path(&file.path)
3879 .map_err(|error| invalid_feed(error.to_string()))?;
3880 let components = normalized.split('/').collect::<Vec<_>>();
3881 if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3882 return Err(invalid_feed("v2 file proof has the wrong shape"));
3883 }
3884 let mut directory_root = root.to_string();
3885 for (index, step) in file.proof.iter().enumerate() {
3886 if step.directory_root != directory_root || step.component != components[index] {
3887 return Err(invalid_feed(
3888 "v2 file proof path chain differs from its manifest",
3889 ));
3890 }
3891 if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3892 .map_err(|error| invalid_feed(error.to_string()))?
3893 {
3894 return Err(invalid_feed("v2 file proof failed verification"));
3895 }
3896 let entry = match &step.proof {
3897 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3898 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3899 return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3900 }
3901 };
3902 if index + 1 == components.len() {
3903 if entry.kind != crate::linkmd_v2::EntryKind::Blob
3904 || entry.child_hash != file.sha256
3905 || entry.bytes != Some(file.bytes)
3906 {
3907 return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3908 }
3909 } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3910 return Err(invalid_feed("v2 file proof traversed a non-directory"));
3911 } else {
3912 directory_root = entry.child_hash.clone();
3913 }
3914 }
3915 Ok(())
3916}
3917
3918fn v2_manifest(
3919 cfg: &HubConfig,
3920 brain: &str,
3921 pointer: Option<&V2PointerBody>,
3922) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3923 let Some(pointer) = pointer else {
3924 return Ok(std::collections::BTreeMap::new());
3925 };
3926 let Some(root) = pointer.content_root.as_deref() else {
3927 return Ok(std::collections::BTreeMap::new());
3928 };
3929 let mut files = std::collections::BTreeMap::new();
3930 let mut after = String::new();
3931 loop {
3932 let encoded_after: String =
3933 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3934 let path = format!(
3935 "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3936 pointer.commit_hash
3937 );
3938 let value = ensure_ok(
3939 request_capped(
3940 cfg,
3941 "GET",
3942 &path,
3943 None,
3944 Auth::Required,
3945 MAX_FEED_RESPONSE_BYTES,
3946 )?,
3947 "v2 file manifest",
3948 )?;
3949 let page: V2ManifestPage = serde_json::from_value(value)
3950 .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3951 if page.v != 2
3952 || page.commit != pointer.commit_hash
3953 || page.content_root.as_deref() != Some(root)
3954 || page.files.len() > 500
3955 {
3956 return Err(invalid_feed(
3957 "v2 file manifest is not bound to the verified head",
3958 ));
3959 }
3960 for file in page.files {
3961 verify_v2_file_proof(root, &file)?;
3962 if files
3963 .insert(
3964 file.path.clone(),
3965 V2BaselineFile {
3966 sha256: file.sha256,
3967 bytes: file.bytes,
3968 proof: Some(file.proof),
3969 },
3970 )
3971 .is_some()
3972 {
3973 return Err(invalid_feed("v2 file manifest repeats a path"));
3974 }
3975 if files.len() > MAX_PUSH_FILES {
3976 return Err(invalid_feed(
3977 "v2 file manifest exceeds the file-count bound",
3978 ));
3979 }
3980 }
3981 match page.next_cursor {
3982 None => break,
3983 Some(next) if next > after => after = next,
3984 Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3985 }
3986 }
3987 Ok(files)
3988}
3989
3990fn v2_manifest_file(
3995 cfg: &HubConfig,
3996 brain: &str,
3997 pointer: &V2PointerBody,
3998 path: &str,
3999) -> LinkResult<Option<V2BaselineFile>> {
4000 let Some(root) = pointer.content_root.as_deref() else {
4001 return Ok(None);
4002 };
4003 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
4004 path: error.to_string(),
4005 })?;
4006 let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
4007 let response = request_capped(
4008 cfg,
4009 "GET",
4010 &format!(
4011 "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
4012 pointer.commit_hash
4013 ),
4014 None,
4015 Auth::Required,
4016 MAX_FEED_RESPONSE_BYTES,
4017 )?;
4018 if response.status == 404 {
4022 return Ok(None);
4023 }
4024 let value = ensure_ok(response, "v2 exact file proof")?;
4025 let mut page: V2ManifestPage = serde_json::from_value(value)
4026 .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
4027 if page.v != 2
4028 || page.commit != pointer.commit_hash
4029 || page.content_root.as_deref() != Some(root)
4030 || page.next_cursor.is_some()
4031 || page.files.len() != 1
4032 || page.files[0].path != path
4033 {
4034 return Err(invalid_feed(
4035 "v2 exact file proof is not bound to the requested signed path",
4036 ));
4037 }
4038 let file = page.files.pop().expect("exactly one file was checked");
4039 verify_v2_file_proof(root, &file)?;
4040 Ok(Some(V2BaselineFile {
4041 sha256: file.sha256,
4042 bytes: file.bytes,
4043 proof: Some(file.proof),
4044 }))
4045}
4046
4047fn v2_manifest_file_by_id(
4052 cfg: &HubConfig,
4053 brain: &str,
4054 pointer: &V2PointerBody,
4055 id: &str,
4056) -> LinkResult<(String, V2BaselineFile)> {
4057 let root = pointer
4058 .content_root
4059 .as_deref()
4060 .ok_or_else(|| LinkError::Http {
4061 what: "resolve",
4062 status: 404,
4063 message: "record not found".to_string(),
4064 code: Some("NOT_FOUND".to_string()),
4065 details: None,
4066 })?;
4067 if !crate::ulid::is_ulid(id) {
4068 return Err(LinkError::BadAddress {
4069 given: id.to_string(),
4070 reason: BAD_TARGET_REASON.to_string(),
4071 });
4072 }
4073 let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
4074 let value = ensure_ok(
4075 request_capped(
4076 cfg,
4077 "GET",
4078 &format!(
4079 "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
4080 pointer.commit_hash
4081 ),
4082 None,
4083 Auth::Required,
4084 MAX_FEED_RESPONSE_BYTES,
4085 )?,
4086 "v2 exact id proof",
4087 )?;
4088 let mut page: V2ManifestPage = serde_json::from_value(value)
4089 .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
4090 if page.v != 2
4091 || page.commit != pointer.commit_hash
4092 || page.content_root.as_deref() != Some(root)
4093 || page.next_cursor.is_some()
4094 || page.files.len() != 1
4095 {
4096 return Err(invalid_feed(
4097 "v2 exact id proof is not bound to one signed path",
4098 ));
4099 }
4100 let file = page.files.pop().expect("exactly one file was checked");
4101 if !safe_store_rel_path(&file.path)
4102 || !file.path.ends_with(".md")
4103 || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
4104 {
4105 return Err(invalid_feed("v2 exact id proof has an invalid record path"));
4106 }
4107 verify_v2_file_proof(root, &file)?;
4108 Ok((
4109 file.path,
4110 V2BaselineFile {
4111 sha256: file.sha256,
4112 bytes: file.bytes,
4113 proof: Some(file.proof),
4114 },
4115 ))
4116}
4117
4118fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
4119 crate::linkmd_v2::normalize_path(&item.path)
4120 .map_err(|error| invalid_feed(error.to_string()))?;
4121 if !is_sha256(&item.blob_sha256)
4122 || !is_sha256(&item.leaf_hash)
4123 || item.bytes > MAX_ASSET_BYTES
4124 || item.wrappers.is_empty()
4125 || !matches!(item.disposition.as_str(), "hosted" | "withheld")
4126 || item
4127 .wrappers
4128 .iter()
4129 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4130 {
4131 return Err(invalid_feed("v2 asset manifest item is invalid"));
4132 }
4133 let leaf = json!({
4134 "blob_sha256": item.blob_sha256,
4135 "bytes": item.bytes,
4136 "disposition": item.disposition,
4137 "media_type": item.media_type,
4138 "path": item.path,
4139 "required": item.required,
4140 "v": 2,
4141 "wrappers": item.wrappers,
4142 });
4143 if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
4144 .map_err(|error| invalid_feed(error.to_string()))?
4145 != item.leaf_hash
4146 || !crate::linkmd_v2::verify_proof_with_domain(
4147 root,
4148 &item.path,
4149 &item.proof,
4150 crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
4151 )
4152 .map_err(|error| invalid_feed(error.to_string()))?
4153 {
4154 return Err(invalid_feed("v2 asset inclusion proof failed"));
4155 }
4156 match &item.proof {
4157 crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
4158 if entry.name == item.path
4159 && entry.kind == crate::linkmd_v2::EntryKind::Blob
4160 && entry.child_hash == item.leaf_hash
4161 && entry.bytes == Some(item.bytes) =>
4162 {
4163 Ok(())
4164 }
4165 _ => Err(invalid_feed(
4166 "v2 asset proof leaf differs from its manifest",
4167 )),
4168 }
4169}
4170
4171fn v2_asset_manifest(
4172 cfg: &HubConfig,
4173 brain: &str,
4174 pointer: Option<&V2PointerBody>,
4175) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
4176 let Some(pointer) = pointer else {
4177 return Ok(std::collections::BTreeMap::new());
4178 };
4179 let Some(root) = pointer.asset_root.as_deref() else {
4180 return Ok(std::collections::BTreeMap::new());
4181 };
4182 let mut assets = std::collections::BTreeMap::new();
4183 let mut after = String::new();
4184 loop {
4185 let encoded_after: String =
4186 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4187 let path = format!(
4188 "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4189 pointer.commit_hash
4190 );
4191 let value = ensure_ok(
4192 request_capped(
4193 cfg,
4194 "GET",
4195 &path,
4196 None,
4197 Auth::Required,
4198 MAX_FEED_RESPONSE_BYTES,
4199 )?,
4200 "v2 asset manifest",
4201 )?;
4202 let page: V2AssetManifestPage = serde_json::from_value(value)
4203 .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4204 if page.v != 2
4205 || page.commit != pointer.commit_hash
4206 || page.asset_root.as_deref() != Some(root)
4207 || page.assets.len() > 500
4208 {
4209 return Err(invalid_feed(
4210 "v2 asset manifest is not bound to the verified head",
4211 ));
4212 }
4213 for item in page.assets {
4214 verify_v2_asset_proof(root, &item)?;
4215 let path = item.path.clone();
4216 if assets
4217 .insert(
4218 path,
4219 V2BaselineAsset {
4220 blob_sha256: item.blob_sha256,
4221 bytes: item.bytes,
4222 media_type: item.media_type,
4223 wrappers: item.wrappers,
4224 required: item.required,
4225 disposition: item.disposition,
4226 leaf_hash: item.leaf_hash,
4227 },
4228 )
4229 .is_some()
4230 {
4231 return Err(invalid_feed("v2 asset manifest repeats a path"));
4232 }
4233 if assets.len() > MAX_PUSH_FILES {
4234 return Err(invalid_feed(
4235 "v2 asset manifest exceeds the item-count bound",
4236 ));
4237 }
4238 }
4239 match page.next_cursor {
4240 None => break,
4241 Some(next) if next > after => after = next,
4242 Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4243 }
4244 }
4245 Ok(assets)
4246}
4247
4248fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4249 crate::AssetRecord {
4250 path: path.to_string(),
4251 sha256: asset.blob_sha256.clone(),
4252 bytes: asset.bytes,
4253 media_type: asset.media_type.clone(),
4254 wrappers: asset.wrappers.clone(),
4255 required: asset.required,
4256 }
4257}
4258
4259fn v2_asset_resumes_hosting(
4260 remote: Option<&V2BaselineAsset>,
4261 path: &str,
4262 record: &crate::AssetRecord,
4263 disposition: &str,
4264) -> bool {
4265 remote.is_some_and(|asset| {
4266 asset.disposition == "withheld"
4267 && disposition == "hosted"
4268 && v2_asset_record(asset, path) == *record
4269 })
4270}
4271
4272fn v2_asset_inherits_withheld_absence(
4273 base: Option<&V2BaselineAsset>,
4274 base_record: Option<&crate::AssetRecord>,
4275 local_record: Option<&crate::AssetRecord>,
4276 raw_present: bool,
4277) -> bool {
4278 !raw_present
4279 && base.is_some_and(|asset| asset.disposition == "withheld")
4280 && local_record == base_record
4281}
4282
4283fn v2_asset_record_manifest_bytes(
4284 assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4285) -> LinkResult<Vec<u8>> {
4286 let mut bytes = Vec::new();
4287 for (path, asset) in assets {
4288 if asset.path != *path {
4289 return Err(invalid_feed(
4290 "local asset manifest key differs from its record path",
4291 ));
4292 }
4293 serde_json::to_writer(&mut bytes, asset)
4294 .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4295 bytes.push(b'\n');
4296 }
4297 Ok(bytes)
4298}
4299
4300fn v2_local_asset_records(
4301 store: &Store,
4302) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4303 let assets = crate::assets::read_manifest(store)
4304 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?;
4305 if assets.iter().any(|asset| asset.bytes > MAX_ASSET_BYTES) {
4306 return Err(LinkError::InvalidPack {
4307 message: "local asset exceeds the link.md v2 per-blob limit".to_string(),
4308 });
4309 }
4310 Ok(assets
4311 .into_iter()
4312 .map(|asset| (asset.path.clone(), asset))
4313 .collect())
4314}
4315
4316fn v2_asset_records_match_remote(
4317 local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4318 remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4319) -> bool {
4320 local.len() == remote.len()
4321 && remote
4322 .iter()
4323 .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4324}
4325
4326#[derive(Debug, Clone, PartialEq, Eq)]
4327struct V2PulledMerge<T> {
4328 records: std::collections::BTreeMap<String, T>,
4329 accept_remote: std::collections::BTreeSet<String>,
4330 conflicts: Vec<String>,
4331}
4332
4333fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4339 base: &std::collections::BTreeMap<String, Base>,
4340 remote: &std::collections::BTreeMap<String, Remote>,
4341 local: &std::collections::BTreeMap<String, Record>,
4342 base_record: BaseRecord,
4343 remote_record: RemoteRecord,
4344 keep_local: KeepLocal,
4345) -> V2PulledMerge<Record>
4346where
4347 Record: Clone + Eq,
4348 BaseRecord: Fn(&Base, &str) -> Record,
4349 RemoteRecord: Fn(&Remote, &str) -> Record,
4350 KeepLocal: Fn(&str) -> bool,
4351{
4352 let paths = base
4353 .keys()
4354 .chain(remote.keys())
4355 .chain(local.keys())
4356 .cloned()
4357 .collect::<std::collections::BTreeSet<_>>();
4358 let mut records = local.clone();
4359 let mut accept_remote = std::collections::BTreeSet::new();
4360 let mut conflicts = Vec::new();
4361 for path in paths {
4362 if keep_local(&path) {
4363 continue;
4364 }
4365 let base_value = base.get(&path).map(|value| base_record(value, &path));
4366 let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4367 let local_value = local.get(&path).cloned();
4368 if local_value != base_value && remote_value != base_value && local_value != remote_value {
4369 conflicts.push(path);
4370 continue;
4371 }
4372 if local_value == base_value || local_value == remote_value {
4373 accept_remote.insert(path.clone());
4374 match remote_value {
4375 Some(value) => {
4376 records.insert(path, value);
4377 }
4378 None => {
4379 records.remove(&path);
4380 }
4381 }
4382 }
4383 }
4384 V2PulledMerge {
4385 records,
4386 accept_remote,
4387 conflicts,
4388 }
4389}
4390
4391fn sign_verified_v2_candidate(
4392 cfg: &HubConfig,
4393 head: &V2VerifiedHead,
4394 expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4395 expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4396 mutation_id: &str,
4397 request_body: &Value,
4398 challenge_value: &Value,
4399) -> LinkResult<(String, String, String)> {
4400 if head.view_kind != "full" {
4401 return Err(invalid_feed(
4402 "a scoped self-custody writer must use the proposal workflow",
4403 ));
4404 }
4405 if head.identity.custody != "self" {
4406 return Err(invalid_feed(
4407 "a hub-custodied brain unexpectedly requested an external signature",
4408 ));
4409 }
4410 let key = cfg
4411 .brain_key
4412 .as_ref()
4413 .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4414 if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4415 || key.public_key_spki != head.identity.public_key_spki
4416 {
4417 return Err(bad_agent_key(
4418 "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4419 ));
4420 }
4421 let challenge_id = challenge_value
4422 .get("id")
4423 .and_then(Value::as_str)
4424 .filter(|id| crate::ulid::is_ulid(id))
4425 .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4426 let expected_endpoint = format!(
4427 "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4428 head.brain_id
4429 );
4430 if challenge_value
4431 .get("candidate_endpoint")
4432 .and_then(Value::as_str)
4433 != Some(expected_endpoint.as_str())
4434 {
4435 return Err(invalid_feed(
4436 "self-custody challenge candidate endpoint is not origin-bound",
4437 ));
4438 }
4439
4440 let mut files = std::collections::BTreeMap::new();
4441 let mut after = String::new();
4442 type CandidateCoordinate = (
4443 String,
4444 String,
4445 String,
4446 String,
4447 Option<String>,
4448 Option<String>,
4449 u64,
4450 Option<String>,
4451 );
4452 let mut pinned: Option<CandidateCoordinate> = None;
4453 loop {
4454 let encoded_after: String =
4455 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4456 let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4457 let value = ensure_ok(
4458 request_capped(
4459 cfg,
4460 "GET",
4461 &path,
4462 None,
4463 Auth::Required,
4464 MAX_FEED_RESPONSE_BYTES,
4465 )?,
4466 "v2 self-custody candidate",
4467 )?;
4468 let page: V2SigningCandidatePage = serde_json::from_value(value)
4469 .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4470 if page.v != 2
4471 || page.challenge_id != challenge_id
4472 || page.mutation_id != mutation_id
4473 || page.candidate.seq != page.parent.seq + 1
4474 || page.files.len() > 500
4475 || page.expires_at.is_empty()
4476 {
4477 return Err(invalid_feed(
4478 "self-custody candidate is not bound to this mutation",
4479 ));
4480 }
4481 let coordinate = (
4482 page.request_hash.clone(),
4483 page.candidate.signing_bytes_base64.clone(),
4484 page.candidate.changes_base64.clone(),
4485 page.candidate.actor_claim_base64.clone(),
4486 page.candidate.content_root.clone(),
4487 page.candidate.asset_root.clone(),
4488 page.parent.seq,
4489 page.parent.commit_hash.clone(),
4490 );
4491 if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4492 return Err(invalid_feed(
4493 "self-custody candidate changed between manifest pages",
4494 ));
4495 }
4496 pinned = Some(coordinate);
4497 let root = page
4498 .candidate
4499 .content_root
4500 .as_deref()
4501 .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4502 for file in page.files {
4503 verify_v2_file_proof(root, &file)?;
4504 if files
4505 .insert(
4506 file.path.clone(),
4507 V2BaselineFile {
4508 sha256: file.sha256,
4509 bytes: file.bytes,
4510 proof: Some(file.proof),
4511 },
4512 )
4513 .is_some()
4514 {
4515 return Err(invalid_feed(
4516 "self-custody candidate repeats a manifest path",
4517 ));
4518 }
4519 if files.len() > MAX_PUSH_FILES {
4520 return Err(invalid_feed(
4521 "self-custody candidate exceeds the file-count bound",
4522 ));
4523 }
4524 }
4525 match page.next_cursor {
4526 None => break,
4527 Some(next) if next > after => after = next,
4528 Some(_) => {
4529 return Err(invalid_feed(
4530 "self-custody candidate cursor did not advance",
4531 ))
4532 }
4533 }
4534 }
4535 if files.len() != expected.len()
4536 || files.iter().any(|(path, file)| {
4537 expected.get(path).is_none_or(|expected| {
4538 expected.sha256 != file.sha256 || expected.bytes != file.bytes
4539 })
4540 })
4541 {
4542 return Err(invalid_feed(
4543 "self-custody candidate contains an unexpected file mutation",
4544 ));
4545 }
4546 let mut assets = std::collections::BTreeMap::new();
4547 after.clear();
4548 loop {
4549 let encoded_after: String =
4550 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4551 let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4552 let value = ensure_ok(
4553 request_capped(
4554 cfg,
4555 "GET",
4556 &path,
4557 None,
4558 Auth::Required,
4559 MAX_FEED_RESPONSE_BYTES,
4560 )?,
4561 "v2 self-custody asset candidate",
4562 )?;
4563 let page: V2SigningCandidatePage = serde_json::from_value(value)
4564 .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4565 let coordinate = (
4566 page.request_hash.clone(),
4567 page.candidate.signing_bytes_base64.clone(),
4568 page.candidate.changes_base64.clone(),
4569 page.candidate.actor_claim_base64.clone(),
4570 page.candidate.content_root.clone(),
4571 page.candidate.asset_root.clone(),
4572 page.parent.seq,
4573 page.parent.commit_hash.clone(),
4574 );
4575 if page.v != 2
4576 || page.challenge_id != challenge_id
4577 || page.mutation_id != mutation_id
4578 || page.assets.len() > 500
4579 || pinned.as_ref() != Some(&coordinate)
4580 {
4581 return Err(invalid_feed(
4582 "self-custody asset candidate changed or is not bound",
4583 ));
4584 }
4585 let root = page.candidate.asset_root.as_deref();
4586 if !page.assets.is_empty() && root.is_none() {
4587 return Err(invalid_feed("asset candidate has no asset root"));
4588 }
4589 for item in page.assets {
4590 verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4591 if assets
4592 .insert(
4593 item.path.clone(),
4594 V2BaselineAsset {
4595 blob_sha256: item.blob_sha256,
4596 bytes: item.bytes,
4597 media_type: item.media_type,
4598 wrappers: item.wrappers,
4599 required: item.required,
4600 disposition: item.disposition,
4601 leaf_hash: item.leaf_hash,
4602 },
4603 )
4604 .is_some()
4605 {
4606 return Err(invalid_feed("self-custody candidate repeats an asset"));
4607 }
4608 }
4609 match page.next_cursor {
4610 None => break,
4611 Some(next) if next > after => after = next,
4612 Some(_) => {
4613 return Err(invalid_feed(
4614 "self-custody asset candidate cursor did not advance",
4615 ))
4616 }
4617 }
4618 }
4619 if assets.len() != expected_assets.len()
4620 || assets.iter().any(|(path, asset)| {
4621 expected_assets.get(path).is_none_or(|expected| {
4622 asset.blob_sha256 != expected.blob_sha256
4623 || asset.bytes != expected.bytes
4624 || asset.media_type != expected.media_type
4625 || asset.wrappers != expected.wrappers
4626 || asset.required != expected.required
4627 || asset.disposition != expected.disposition
4628 })
4629 })
4630 {
4631 return Err(invalid_feed(
4632 "self-custody candidate contains an unexpected asset mutation",
4633 ));
4634 }
4635 let Some((
4636 request_hash,
4637 signing_b64,
4638 changes_b64,
4639 actor_b64,
4640 root,
4641 asset_root,
4642 parent_seq,
4643 parent,
4644 )) = pinned
4645 else {
4646 return Err(invalid_feed("self-custody candidate has no manifest"));
4647 };
4648 let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4649 let current_commit = head
4650 .pointer
4651 .as_ref()
4652 .map(|pointer| pointer.commit_hash.clone());
4653 if parent_seq != current_seq || parent != current_commit {
4654 return Err(LinkError::RemoteAdvancedDuringSync);
4655 }
4656 let changes = STANDARD
4657 .decode(changes_b64)
4658 .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4659 let mut expected_changes = json!({
4660 "mutation_id": mutation_id,
4661 "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4662 "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4663 "v": 2,
4664 });
4665 if let Some(withheld_links) = request_body.get("withheld_links") {
4666 expected_changes["withheld_links"] = withheld_links.clone();
4667 }
4668 if let Some(checkout_id) = request_body.get("checkout_id") {
4669 expected_changes["checkout_id"] = checkout_id.clone();
4670 }
4671 let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4672 .map_err(|error| invalid_feed(error.to_string()))?;
4673 if changes != expected_changes_bytes {
4674 return Err(invalid_feed(
4675 "self-custody changeset differs from the requested mutation",
4676 ));
4677 }
4678 let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4679 .map_err(|error| invalid_feed(error.to_string()))?;
4680 let request_value = json!({
4681 "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4682 "brain": head.brain_id,
4683 "changes_sha256": changes_hash,
4684 "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4685 "v": 2,
4686 "v1_bridge": Value::Null,
4687 });
4688 let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4689 .map_err(|error| invalid_feed(error.to_string()))?;
4690 if request_hash != expected_request_hash {
4691 return Err(invalid_feed(
4692 "self-custody request hash differs from the requested mutation",
4693 ));
4694 }
4695 let actor = STANDARD
4696 .decode(actor_b64)
4697 .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4698 let actor_value: Value = serde_json::from_slice(&actor)
4699 .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4700 if crate::linkmd_v2::canonical_bytes(&actor_value)
4701 .map_err(|error| invalid_feed(error.to_string()))?
4702 != actor
4703 {
4704 return Err(invalid_feed("self-custody actor claim is not canonical"));
4705 }
4706 let actor_object = actor_value
4707 .as_object()
4708 .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4709 let actor_claim = actor_object
4710 .get("claim")
4711 .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4712 let actor_public_key = actor_object
4713 .get("public_key")
4714 .and_then(Value::as_str)
4715 .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4716 let actor_fingerprint = actor_object
4717 .get("fingerprint")
4718 .and_then(Value::as_str)
4719 .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4720 let actor_signature = actor_object
4721 .get("sig")
4722 .and_then(Value::as_str)
4723 .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4724 let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4725 .map_err(|error| invalid_feed(error.to_string()))?;
4726 let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4727 let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4728 let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4729 let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4730 let impact = actor_claim
4731 .get("result")
4732 .and_then(|result| result.get("impact"))
4733 .and_then(Value::as_object);
4734 let impact_fields = [
4735 "creates",
4736 "updates",
4737 "deletes",
4738 "withdrawals",
4739 "renames",
4740 "restores",
4741 "asset_changes",
4742 "public_expansions",
4743 "executable_activations",
4744 ];
4745 let impact_is_valid = impact.is_some_and(|impact| {
4746 impact.len() == impact_fields.len() + 1
4747 && impact.get("v").and_then(Value::as_u64) == Some(1)
4748 && impact_fields
4749 .iter()
4750 .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4751 });
4752 if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4753 || head
4754 .trust
4755 .hub_signer
4756 .as_ref()
4757 .is_some_and(|known| known != &expected_actor_signer)
4758 || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4759 || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4760 || actor_claim
4761 .get("candidate")
4762 .and_then(|candidate| candidate.get("changes_sha256"))
4763 .and_then(Value::as_str)
4764 != Some(changes_hash.as_str())
4765 || actor_claim
4766 .get("candidate")
4767 .and_then(|candidate| candidate.get("state_root"))
4768 != Some(&expected_actor_root)
4769 || actor_claim
4770 .get("candidate")
4771 .and_then(|candidate| candidate.get("asset_root"))
4772 != Some(&expected_actor_asset_root)
4773 || actor_claim
4774 .get("candidate")
4775 .and_then(|candidate| candidate.get("control_revision"))
4776 .and_then(Value::as_str)
4777 != Some(head.control_revision.as_str())
4778 || !impact_is_valid
4779 {
4780 return Err(invalid_feed(
4781 "self-custody actor claim does not bind the verified authority",
4782 ));
4783 }
4784 let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4785 .map_err(|error| invalid_feed(error.to_string()))?;
4786 let signing = STANDARD
4787 .decode(signing_b64)
4788 .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4789 let signing_value: Value = serde_json::from_slice(&signing)
4790 .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4791 if crate::linkmd_v2::canonical_bytes(&signing_value)
4792 .map_err(|error| invalid_feed(error.to_string()))?
4793 != signing
4794 {
4795 return Err(invalid_feed("self-custody signing bytes are not canonical"));
4796 }
4797 let pointer = head.pointer.as_ref();
4798 let expected_materializer = pointer
4799 .map(|value| value.materializer.as_str())
4800 .unwrap_or("dbmd-projection-v1");
4801 let expected_parent_commit = request_body
4802 .get("base")
4803 .and_then(|base| base.get("commit_hash"))
4804 .cloned()
4805 .unwrap_or(Value::Null);
4806 let expected_parent_root = request_body
4807 .get("base")
4808 .and_then(|base| base.get("content_root"))
4809 .cloned()
4810 .unwrap_or(Value::Null);
4811 let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4812 let expected_parent_asset_root = request_body
4813 .get("base")
4814 .and_then(|base| base.get("asset_root"))
4815 .cloned()
4816 .unwrap_or(Value::Null);
4817 let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4818 let expected_prev_entry = pointer
4819 .map(|value| Value::String(value.feed_hash.clone()))
4820 .unwrap_or(Value::Null);
4821 let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4822 .map_err(|_| invalid_feed("brain identity history is too large"))?
4823 + 1;
4824 if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4825 || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4826 || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4827 || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4828 || signing_value.get("public_key").and_then(Value::as_str)
4829 != Some(key.public_key_spki.as_str())
4830 || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4831 || signing_value.get("parent_root") != Some(&expected_parent_root)
4832 || signing_value.get("state_root") != Some(&expected_state_root)
4833 || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4834 || signing_value.get("asset_root") != Some(&expected_asset_root)
4835 || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4836 || signing_value.get("changes_sha256").and_then(Value::as_str)
4837 != Some(changes_hash.as_str())
4838 || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4839 || signing_value
4840 .get("control_revision")
4841 .and_then(Value::as_str)
4842 != Some(head.control_revision.as_str())
4843 || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4844 || signing_value.get("v1_bridge") != Some(&Value::Null)
4845 || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4846 {
4847 return Err(invalid_feed(
4848 "self-custody signing bytes do not bind the verified candidate",
4849 ));
4850 }
4851 let pair = agent_keypair(&key.pkcs8)?;
4852 let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4853 Ok((challenge_id.to_string(), signature, expected_actor_signer))
4854}
4855
4856fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4857 let origin = normalized_origin(&cfg.hub)?;
4858 let absolute = if checkout.is_absolute() {
4859 checkout.to_path_buf()
4860 } else {
4861 std::env::current_dir()?.join(checkout)
4862 };
4863 Ok(format!(
4864 "sync-{}.json",
4865 content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4866 ))
4867}
4868
4869fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4870 if let Some(value) = existing {
4871 if !is_sha256(value) {
4872 return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4873 }
4874 return Ok(value.to_string());
4875 }
4876 use ring::rand::SecureRandom as _;
4877 let mut random = [0_u8; 32];
4878 ring::rand::SystemRandom::new()
4879 .fill(&mut random)
4880 .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4881 Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4882}
4883
4884#[cfg(any(unix, windows))]
4885fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4886 let directory = open_trust_dir(cfg)?;
4887 let origin = normalized_origin(&cfg.hub)?;
4888 let name = format!(
4889 "operation-{}.lock",
4890 content_sha256(format!("{origin}\0{brain}").as_bytes())
4891 );
4892 lock_trust_name(&directory, &name)
4893}
4894
4895#[cfg(not(any(unix, windows)))]
4896fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4897 Err(LinkError::UnsupportedPlatform {
4898 operation: "serialized link.md v2 sync",
4899 })
4900}
4901
4902fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4903 left.brain_id == right.brain_id
4904 && left.view_kind == right.view_kind
4905 && left.view_revision == right.view_revision
4906 && left.control_revision == right.control_revision
4907 && match (&left.pointer, &right.pointer) {
4908 (None, None) => true,
4909 (Some(left), Some(right)) => {
4910 left.seq == right.seq
4911 && left.commit_hash == right.commit_hash
4912 && left.content_root == right.content_root
4913 && left.asset_root == right.asset_root
4914 && left.feed_hash == right.feed_hash
4915 }
4916 _ => false,
4917 }
4918}
4919
4920fn v2_baseline_matches_head(head: &V2VerifiedHead, baseline: &V2SyncBaseline) -> bool {
4926 let pointer = head.pointer.as_ref();
4927 baseline.head_seq == Some(pointer.map_or(0, |value| value.seq))
4928 && baseline.commit_hash.as_deref() == pointer.map(|value| value.commit_hash.as_str())
4929 && baseline.content_root.as_deref()
4930 == pointer.and_then(|value| value.content_root.as_deref())
4931 && baseline.asset_root.as_deref() == pointer.and_then(|value| value.asset_root.as_deref())
4932 && baseline.view_kind.as_deref() == Some(head.view_kind.as_str())
4933 && baseline.view_revision.as_deref() == Some(head.view_revision.as_str())
4934 && baseline.control_revision.as_deref() == Some(head.control_revision.as_str())
4935}
4936
4937fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4938 format!(
4939 "---\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"
4940 )
4941 .into_bytes()
4942}
4943
4944fn scoped_projection_sha256(brain: &str) -> String {
4945 content_sha256(&scoped_projection_bytes(brain))
4946}
4947
4948#[derive(Deserialize)]
4949struct LocalScopedViewMarker {
4950 v: u8,
4951 kind: String,
4952 authoritative: bool,
4953 brain: String,
4954 projection_sha256: String,
4955}
4956
4957pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4961 let marker = store
4962 .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4963 .ok()
4964 .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4965 let Some(marker) = marker else {
4966 return false;
4967 };
4968 if marker.v != 1
4969 || marker.kind != "link.md-scoped-view"
4970 || marker.authoritative
4971 || !crate::ulid::is_ulid(&marker.brain)
4972 || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4973 {
4974 return false;
4975 }
4976 store
4977 .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4978 .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4979}
4980
4981fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4982 let mut bytes = serde_json::to_vec_pretty(&json!({
4983 "v": 1,
4984 "kind": "link.md-scoped-view",
4985 "authoritative": false,
4986 "brain": head.brain_id,
4987 "view_revision": head.view_revision,
4988 "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4989 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4990 "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4991 "visible_files": files,
4992 "projection_sha256": scoped_projection_sha256(&head.brain_id),
4993 }))
4994 .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4995 bytes.push(b'\n');
4996 Ok(bytes)
4997}
4998
4999fn refresh_scoped_view_marker(
5000 store: &Store,
5001 head: &V2VerifiedHead,
5002 files: usize,
5003) -> LinkResult<()> {
5004 if head.view_kind == "scoped" {
5005 store.write_atomic(
5006 Path::new(".dbmd/view.json"),
5007 &scoped_view_metadata(head, files)?,
5008 )?;
5009 }
5010 Ok(())
5011}
5012
5013fn ensure_v2_view_compatible(
5014 head: &V2VerifiedHead,
5015 baseline: Option<&V2SyncBaseline>,
5016) -> LinkResult<()> {
5017 let Some(baseline) = baseline else {
5018 return Ok(());
5019 };
5020 match (
5021 baseline.view_kind.as_deref(),
5022 baseline.view_revision.as_deref(),
5023 ) {
5024 (None, None) if head.view_kind == "full" => Ok(()),
5025 (Some(kind), Some(revision))
5026 if kind == head.view_kind && revision == head.view_revision =>
5027 {
5028 Ok(())
5029 }
5030 _ => Err(LinkError::ScopedViewChanged),
5031 }
5032}
5033
5034fn ensure_established_v2_checkout_opened(
5035 head: &V2VerifiedHead,
5036 baseline: Option<&V2SyncBaseline>,
5037 opened: bool,
5038) -> LinkResult<()> {
5039 if baseline.is_none() || opened {
5040 return Ok(());
5041 }
5042 if head.view_kind == "scoped" {
5043 return Err(LinkError::ScopedProjectionModified);
5044 }
5045 Err(LinkError::InvalidPack {
5046 message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
5047 })
5048}
5049
5050fn remove_scoped_projection(
5051 head: &V2VerifiedHead,
5052 baseline: Option<&V2SyncBaseline>,
5053 view: &mut V2LocalView,
5054) -> LinkResult<()> {
5055 if head.view_kind != "scoped" {
5056 return Ok(());
5057 }
5058 let expected = scoped_projection_sha256(&head.brain_id);
5059 if baseline
5060 .and_then(|state| state.projection_sha256.as_deref())
5061 .is_some_and(|pinned| pinned != expected)
5062 {
5063 return Err(LinkError::ScopedViewChanged);
5064 }
5065 if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
5066 return Err(LinkError::ScopedProjectionModified);
5067 }
5068 view.riding.remove("DB.md");
5069 view.scan_cache.remove("DB.md");
5070 view.eligibility.remove("DB.md");
5071 Ok(())
5072}
5073
5074fn local_view_for_v2_push(
5075 store: &Store,
5076 head: &V2VerifiedHead,
5077 baseline: Option<&V2SyncBaseline>,
5078 carried: Option<V2LocalView>,
5079) -> LinkResult<V2LocalView> {
5080 match carried {
5081 Some(view) => Ok(view),
5086 None => {
5087 let hint = baseline.and_then(|state| {
5088 state
5089 .local_policy_digest
5090 .as_deref()
5091 .map(|digest| (digest, &state.scan_cache))
5092 });
5093 let mut view = v2_local_files_cached(store, hint)?;
5094 remove_scoped_projection(head, baseline, &mut view)?;
5095 Ok(view)
5096 }
5097 }
5098}
5099
5100fn files_for_v2_view(
5101 head: &V2VerifiedHead,
5102 mut files: std::collections::BTreeMap<String, V2BaselineFile>,
5103) -> std::collections::BTreeMap<String, V2BaselineFile> {
5104 if head.view_kind == "scoped" {
5105 files.remove("DB.md");
5109 }
5110 files
5111}
5112
5113fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
5114 let baseline: V2SyncBaseline =
5115 serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
5116 if baseline.v != 2
5117 || baseline.origin != normalized_origin(&cfg.hub)?
5118 || baseline.brain != brain
5119 || baseline
5120 .commit_hash
5121 .as_deref()
5122 .is_some_and(|hash| !is_sha256(hash))
5123 || baseline
5124 .content_root
5125 .as_deref()
5126 .is_some_and(|hash| !is_sha256(hash))
5127 || baseline
5128 .asset_root
5129 .as_deref()
5130 .is_some_and(|hash| !is_sha256(hash))
5131 || baseline
5132 .local_policy_digest
5133 .as_deref()
5134 .is_some_and(|hash| !is_sha256(hash))
5135 || baseline
5136 .view_kind
5137 .as_deref()
5138 .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
5139 || baseline
5140 .view_revision
5141 .as_deref()
5142 .is_some_and(|hash| !is_sha256(hash))
5143 || baseline
5144 .control_revision
5145 .as_deref()
5146 .is_some_and(|hash| !is_sha256(hash))
5147 || baseline
5148 .projection_sha256
5149 .as_deref()
5150 .is_some_and(|hash| !is_sha256(hash))
5151 || (baseline.view_kind.as_deref() == Some("scoped")
5152 && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
5153 || baseline.files.len() > MAX_PUSH_FILES
5154 || baseline.scan_cache.len() > MAX_PUSH_FILES
5155 || baseline.assets.len() > MAX_PUSH_FILES
5156 || baseline.local_eligibility.len() > MAX_PUSH_FILES
5157 || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
5158 || baseline.files.iter().any(|(path, file)| {
5159 crate::linkmd_v2::normalize_path(path).is_err()
5160 || !is_sha256(&file.sha256)
5161 || file.bytes > MAX_STORE_BYTES
5162 })
5163 || baseline.scan_cache.iter().any(|(path, file)| {
5164 crate::linkmd_v2::normalize_path(path).is_err()
5165 || !file.fingerprint.starts_with("unix-v1:")
5166 || file.fingerprint.len() > 256
5167 || !file.fingerprint.is_ascii()
5168 || !is_sha256(&file.sha256)
5169 || file.bytes > MAX_STORE_BYTES
5170 || file.withheld_targets.len() > MAX_PUSH_FILES
5171 || file.withheld_targets.iter().any(|target| {
5172 target.len() > MAX_STORE_PATH_BYTES
5173 || crate::linkmd_v2::normalize_path(target).is_err()
5174 })
5175 || baseline.files.get(path).is_none_or(|baseline_file| {
5176 baseline_file.sha256 != file.sha256 || baseline_file.bytes != file.bytes
5177 })
5178 })
5179 || baseline.assets.iter().any(|(path, asset)| {
5180 crate::linkmd_v2::normalize_path(path).is_err()
5181 || !is_sha256(&asset.blob_sha256)
5182 || !is_sha256(&asset.leaf_hash)
5183 || asset.bytes > MAX_ASSET_BYTES
5184 || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
5185 || asset.wrappers.is_empty()
5186 || asset
5187 .wrappers
5188 .iter()
5189 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
5190 })
5191 || baseline
5192 .local_eligibility
5193 .keys()
5194 .chain(baseline.remote_copy_remains.keys())
5195 .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
5196 || baseline
5197 .remote_copy_remains
5198 .values()
5199 .any(|hash| !is_sha256(hash))
5200 || baseline
5201 .checkout_id
5202 .as_deref()
5203 .is_some_and(|checkout_id| !is_sha256(checkout_id))
5204 {
5205 return Err(invalid_feed("v2 sync baseline failed validation"));
5206 }
5207 Ok(baseline)
5208}
5209
5210#[cfg(unix)]
5211fn load_v2_baseline_in(
5212 cfg: &HubConfig,
5213 brain: &str,
5214 directory: &TrustDirectory,
5215 name_string: &str,
5216) -> LinkResult<Option<V2SyncBaseline>> {
5217 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5218 let name = c_name(name_string.as_bytes(), name_string)?;
5219 let fd = unsafe {
5220 libc::openat(
5221 directory.as_raw_fd(),
5222 name.as_ptr(),
5223 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5224 )
5225 };
5226 if fd < 0 {
5227 let error = std::io::Error::last_os_error();
5228 return if error.kind() == std::io::ErrorKind::NotFound {
5229 Ok(None)
5230 } else {
5231 Err(LinkError::UnsafePath {
5232 path: name_string.to_string(),
5233 })
5234 };
5235 }
5236 let file = unsafe { std::fs::File::from_raw_fd(fd) };
5237 let mut bytes = Vec::new();
5238 file.take(MAX_V2_BASELINE_BYTES + 1)
5239 .read_to_end(&mut bytes)?;
5240 if bytes.len() as u64 > MAX_V2_BASELINE_BYTES {
5241 return Err(invalid_feed("v2 sync baseline is oversized"));
5242 }
5243 Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
5244}
5245
5246#[cfg(unix)]
5247fn load_v2_baseline(
5248 cfg: &HubConfig,
5249 brain: &str,
5250 checkout: &Path,
5251) -> LinkResult<Option<V2SyncBaseline>> {
5252 let directory = open_trust_dir(cfg)?;
5253 let name = v2_baseline_name(cfg, brain, checkout)?;
5254 let _lock = lock_trust_name(&directory, &name)?;
5255 load_v2_baseline_in(cfg, brain, &directory, &name)
5256}
5257
5258#[cfg(windows)]
5259fn load_v2_baseline_in(
5260 cfg: &HubConfig,
5261 brain: &str,
5262 directory: &TrustDirectory,
5263 name: &str,
5264) -> LinkResult<Option<V2SyncBaseline>> {
5265 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
5266 match reader.read(Path::new(&name), MAX_V2_BASELINE_BYTES) {
5267 Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5268 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5269 Err(_) => Err(LinkError::UnsafePath {
5270 path: name.to_string(),
5271 }),
5272 }
5273}
5274
5275#[cfg(windows)]
5276fn load_v2_baseline(
5277 cfg: &HubConfig,
5278 brain: &str,
5279 checkout: &Path,
5280) -> LinkResult<Option<V2SyncBaseline>> {
5281 let directory = open_trust_dir(cfg)?;
5282 let name = v2_baseline_name(cfg, brain, checkout)?;
5283 let _lock = lock_trust_name(&directory, &name)?;
5284 load_v2_baseline_in(cfg, brain, &directory, &name)
5285}
5286
5287#[cfg(not(any(unix, windows)))]
5288fn load_v2_baseline(
5289 _cfg: &HubConfig,
5290 _brain: &str,
5291 _checkout: &Path,
5292) -> LinkResult<Option<V2SyncBaseline>> {
5293 Err(LinkError::UnsupportedPlatform {
5294 operation: "verified link.md v2 baseline",
5295 })
5296}
5297
5298#[cfg(unix)]
5299fn save_v2_baseline(
5300 cfg: &HubConfig,
5301 brain: &str,
5302 checkout: &Path,
5303 baseline: &V2SyncBaseline,
5304) -> LinkResult<()> {
5305 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5306 let directory = open_trust_dir(cfg)?;
5307 let name_string = v2_baseline_name(cfg, brain, checkout)?;
5308 let _lock = lock_trust_name(&directory, &name_string)?;
5309 let name = c_name(name_string.as_bytes(), &name_string)?;
5310 let mut bytes = serde_json::to_vec(baseline)
5311 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5312 bytes.push(b'\n');
5313 if bytes.len() as u64 > MAX_V2_BASELINE_BYTES {
5314 return Err(invalid_feed("v2 sync baseline is oversized"));
5315 }
5316 let temp_string = format!(
5317 ".{name_string}.tmp.{}-{}",
5318 std::process::id(),
5319 std::time::SystemTime::now()
5320 .duration_since(std::time::UNIX_EPOCH)
5321 .unwrap_or_default()
5322 .as_nanos()
5323 );
5324 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5325 let fd = unsafe {
5326 libc::openat(
5327 directory.as_raw_fd(),
5328 temp.as_ptr(),
5329 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5330 0o600,
5331 )
5332 };
5333 if fd < 0 {
5334 return Err(std::io::Error::last_os_error().into());
5335 }
5336 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5337 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5338 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5339 return Err(error.into());
5340 }
5341 drop(file);
5342 if unsafe {
5343 libc::renameat(
5344 directory.as_raw_fd(),
5345 temp.as_ptr(),
5346 directory.as_raw_fd(),
5347 name.as_ptr(),
5348 )
5349 } != 0
5350 {
5351 let error = std::io::Error::last_os_error();
5352 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5353 return Err(error.into());
5354 }
5355 directory.sync_all()?;
5356 Ok(())
5357}
5358
5359#[cfg(windows)]
5360fn save_v2_baseline(
5361 cfg: &HubConfig,
5362 brain: &str,
5363 checkout: &Path,
5364 baseline: &V2SyncBaseline,
5365) -> LinkResult<()> {
5366 let directory = open_trust_dir(cfg)?;
5367 let name = v2_baseline_name(cfg, brain, checkout)?;
5368 let _lock = lock_trust_name(&directory, &name)?;
5369 let mut bytes = serde_json::to_vec(baseline)
5370 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5371 bytes.push(b'\n');
5372 if bytes.len() as u64 > MAX_V2_BASELINE_BYTES {
5373 return Err(invalid_feed("v2 sync baseline is oversized"));
5374 }
5375 crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5376 Ok(())
5377}
5378
5379#[cfg(not(any(unix, windows)))]
5380fn save_v2_baseline(
5381 _cfg: &HubConfig,
5382 _brain: &str,
5383 _checkout: &Path,
5384 _baseline: &V2SyncBaseline,
5385) -> LinkResult<()> {
5386 Err(LinkError::UnsupportedPlatform {
5387 operation: "verified link.md v2 baseline",
5388 })
5389}
5390
5391fn v2_baseline_from_head(
5392 cfg: &HubConfig,
5393 head: &V2VerifiedHead,
5394 files: std::collections::BTreeMap<String, V2BaselineFile>,
5395 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5396 local: Option<&V2LocalView>,
5397 checkout_id: Option<&str>,
5398) -> LinkResult<V2SyncBaseline> {
5399 let mut local_eligibility = local
5400 .map(|view| view.eligibility.clone())
5401 .unwrap_or_default();
5402 if let Some(view) = local {
5403 for path in files.keys() {
5404 local_eligibility
5405 .entry(path.clone())
5406 .or_insert_with(|| !view.policy.keeps_home(path));
5407 }
5408 }
5409 let remote_copy_remains = local_eligibility
5410 .iter()
5411 .filter(|(_, riding)| !**riding)
5412 .filter_map(|(path, _)| {
5413 files
5414 .get(path)
5415 .map(|file| (path.clone(), file.sha256.clone()))
5416 })
5417 .collect();
5418 let scan_cache = local
5423 .map(|view| {
5424 view.scan_cache
5425 .iter()
5426 .filter(|(path, cached)| {
5427 files.get(*path).is_some_and(|remote| {
5428 remote.sha256 == cached.sha256 && remote.bytes == cached.bytes
5429 })
5430 })
5431 .map(|(path, cached)| (path.clone(), cached.clone()))
5432 .collect()
5433 })
5434 .unwrap_or_default();
5435 Ok(V2SyncBaseline {
5436 v: 2,
5437 origin: normalized_origin(&cfg.hub)?,
5438 brain: head.brain_id.clone(),
5439 checkout_id: Some(v2_checkout_id(checkout_id)?),
5440 head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5441 commit_hash: head
5442 .pointer
5443 .as_ref()
5444 .map(|pointer| pointer.commit_hash.clone()),
5445 content_root: head
5446 .pointer
5447 .as_ref()
5448 .and_then(|pointer| pointer.content_root.clone()),
5449 asset_root: head
5450 .pointer
5451 .as_ref()
5452 .and_then(|pointer| pointer.asset_root.clone()),
5453 assets,
5454 view_kind: Some(head.view_kind.clone()),
5455 view_revision: Some(head.view_revision.clone()),
5456 control_revision: Some(head.control_revision.clone()),
5457 projection_sha256: (head.view_kind == "scoped")
5458 .then(|| scoped_projection_sha256(&head.brain_id)),
5459 files,
5460 scan_cache,
5461 local_policy_digest: local.map(|view| view.policy.digest.clone()),
5462 local_eligibility,
5463 remote_copy_remains,
5464 })
5465}
5466
5467#[cfg(unix)]
5468fn v2_scan_fingerprint_at(metadata: &std::fs::Metadata, now_ns: i128) -> Option<String> {
5469 use std::os::unix::fs::MetadataExt as _;
5470
5471 let mtime_ns = i128::from(metadata.mtime()) * 1_000_000_000 + i128::from(metadata.mtime_nsec());
5479 let ctime_ns = i128::from(metadata.ctime()) * 1_000_000_000 + i128::from(metadata.ctime_nsec());
5480 let newest = mtime_ns.max(ctime_ns);
5481 if newest < 0 || now_ns.checked_sub(newest)? < 2_000_000_000 {
5482 return None;
5483 }
5484 Some(format!(
5485 "unix-v1:{:x}:{:x}:{:x}:{:x}:{:x}:{:x}:{:x}",
5486 metadata.dev(),
5487 metadata.ino(),
5488 metadata.len(),
5489 metadata.mtime(),
5490 metadata.mtime_nsec(),
5491 metadata.ctime(),
5492 metadata.ctime_nsec(),
5493 ))
5494}
5495
5496#[cfg(unix)]
5497fn v2_scan_fingerprint(metadata: &std::fs::Metadata) -> Option<String> {
5498 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?;
5499 let now_ns = i128::from(now.as_secs()) * 1_000_000_000 + i128::from(now.subsec_nanos());
5500 v2_scan_fingerprint_at(metadata, now_ns)
5501}
5502
5503#[cfg(not(unix))]
5504fn v2_scan_fingerprint(_metadata: &std::fs::Metadata) -> Option<String> {
5505 None
5508}
5509
5510fn v2_local_files_cached(
5511 store: &Store,
5512 prior_cache: Option<(&str, &std::collections::BTreeMap<String, V2ScanCacheFile>)>,
5513) -> LinkResult<V2LocalView> {
5514 let policy = crate::linkmd_sync_policy::load(store)
5515 .map_err(|message| LinkError::InvalidPack { message })?;
5516 let prior_cache = prior_cache
5517 .filter(|(digest, _)| *digest == policy.digest.as_str())
5518 .map(|(_, cache)| cache);
5519 let asset_paths = crate::assets::read_manifest(store)
5520 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5521 .into_iter()
5522 .map(|asset| asset.path)
5523 .collect::<std::collections::BTreeSet<_>>();
5524 let mut result = std::collections::BTreeMap::new();
5525 let mut scan_cache = std::collections::BTreeMap::new();
5526 let mut eligibility = std::collections::BTreeMap::new();
5527 let mut withheld_links = Vec::<V2WithheldLink>::new();
5528 let mut total = 0_u64;
5529 let mut paths = vec![PathBuf::from("DB.md")];
5530 paths.extend(store.walk()?);
5531 for relative in paths {
5532 let path = relative.to_string_lossy().replace('\\', "/");
5533 if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5535 continue;
5536 }
5537 if asset_paths.contains(&path) {
5538 continue;
5539 }
5540 crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5541 path: error.to_string(),
5542 })?;
5543 let riding = !policy.keeps_home(&path);
5544 eligibility.insert(path.clone(), riding);
5545 if !riding {
5546 continue;
5547 }
5548 let remaining = MAX_STORE_BYTES.saturating_sub(total);
5549 let mut file = store.open_regular(&relative)?;
5550 let before = file.metadata()?;
5551 if before.len() > remaining {
5552 return Err(LinkError::PushTooLarge {
5553 detail: format!("more than {MAX_STORE_BYTES} uncompressed bytes"),
5554 });
5555 }
5556 let fingerprint = v2_scan_fingerprint(&before);
5557 if let (Some(fingerprint), Some(cached)) = (
5558 fingerprint.as_deref(),
5559 prior_cache.and_then(|cache| cache.get(&path)),
5560 ) {
5561 if cached.fingerprint == fingerprint && cached.bytes == before.len() {
5562 total = total
5563 .checked_add(cached.bytes)
5564 .ok_or_else(|| LinkError::PushTooLarge {
5565 detail: "v2 local byte count overflow".to_string(),
5566 })?;
5567 result.insert(path.clone(), (cached.sha256.clone(), cached.bytes));
5568 for target in &cached.withheld_targets {
5569 withheld_links.push(V2WithheldLink {
5570 source: path.clone(),
5571 target: target.clone(),
5572 });
5573 }
5574 scan_cache.insert(path, cached.clone());
5575 continue;
5576 }
5577 }
5578 let mut bytes = Vec::with_capacity(before.len().min(8 * 1024 * 1024) as usize);
5579 Read::by_ref(&mut file)
5580 .take(remaining.saturating_add(1))
5581 .read_to_end(&mut bytes)?;
5582 if bytes.len() as u64 > remaining {
5583 return Err(LinkError::PushTooLarge {
5584 detail: format!("more than {MAX_STORE_BYTES} uncompressed bytes"),
5585 });
5586 }
5587 total = total
5588 .checked_add(bytes.len() as u64)
5589 .ok_or_else(|| LinkError::PushTooLarge {
5590 detail: "v2 local byte count overflow".to_string(),
5591 })?;
5592 if total > MAX_STORE_BYTES {
5593 return Err(LinkError::PushTooLarge {
5594 detail: format!("{total} uncompressed bytes"),
5595 });
5596 }
5597 if std::str::from_utf8(&bytes).is_err() {
5598 return Err(LinkError::NotUtf8 { path });
5599 }
5600 let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5601 let targets = crate::store::extract_edge_targets(text)
5606 .into_iter()
5607 .map(|target| format!("{target}.md"))
5608 .filter(|target| policy.keeps_home(target))
5609 .collect::<Vec<_>>();
5610 for target in &targets {
5611 withheld_links.push(V2WithheldLink {
5612 source: path.clone(),
5613 target: target.clone(),
5614 });
5615 }
5616 let sha256 = content_sha256(&bytes);
5617 result.insert(path.clone(), (sha256.clone(), bytes.len() as u64));
5618 let after = file.metadata()?;
5619 if before.len() == bytes.len() as u64
5620 && v2_scan_fingerprint(&before) == v2_scan_fingerprint(&after)
5621 {
5622 if let Some(fingerprint) = v2_scan_fingerprint(&after) {
5623 scan_cache.insert(
5624 path,
5625 V2ScanCacheFile {
5626 fingerprint,
5627 sha256,
5628 bytes: bytes.len() as u64,
5629 withheld_targets: targets,
5630 },
5631 );
5632 }
5633 }
5634 }
5635 withheld_links.sort();
5636 withheld_links.dedup();
5637 Ok(V2LocalView {
5638 riding: result,
5639 scan_cache,
5640 eligibility,
5641 policy,
5642 withheld_links,
5643 })
5644}
5645
5646fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5647 v2_local_files_cached(store, None)
5648}
5649
5650#[derive(Debug, Clone, Deserialize)]
5651struct V2DownloadItem {
5652 path: String,
5653 sha256: String,
5654 bytes: u64,
5655 url: String,
5656 method: String,
5657}
5658
5659#[derive(Debug, Deserialize)]
5660struct V2DownloadWindow {
5661 v: u8,
5662 commit: String,
5663 downloads: Vec<V2DownloadItem>,
5664}
5665
5666#[derive(Debug, Deserialize)]
5667struct V2BulkStreamHeader {
5668 v: u8,
5669 path: String,
5670 sha256: String,
5671 bytes: u64,
5672}
5673
5674fn parse_v2_bulk_stream(
5675 bytes: &[u8],
5676 expected: &[(&String, &V2BaselineFile)],
5677) -> LinkResult<Vec<(String, Vec<u8>)>> {
5678 if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5679 return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5680 }
5681 let mut cursor = V2_BULK_STREAM_MAGIC.len();
5682 let mut result = Vec::with_capacity(expected.len());
5683 for (expected_path, expected_file) in expected {
5684 let length_bytes = bytes
5685 .get(cursor..cursor + 4)
5686 .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5687 cursor += 4;
5688 let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5689 if header_len == 0 || header_len > 4 * 1024 {
5690 return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5691 }
5692 let header_bytes = bytes
5693 .get(cursor..cursor + header_len)
5694 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5695 cursor += header_len;
5696 let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5697 .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5698 if header.v != 2
5699 || &header.path != *expected_path
5700 || header.sha256 != expected_file.sha256
5701 || header.bytes != expected_file.bytes
5702 || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5703 {
5704 return Err(invalid_feed(
5705 "v2 bulk stream frame differs from its proven manifest entry",
5706 ));
5707 }
5708 let body_len = usize::try_from(header.bytes)
5709 .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5710 let body = bytes
5711 .get(cursor..cursor + body_len)
5712 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5713 cursor += body_len;
5714 if content_sha256(body) != header.sha256 {
5715 return Err(invalid_feed(
5716 "v2 bulk stream file differs from its proven manifest entry",
5717 ));
5718 }
5719 result.push((header.path, body.to_vec()));
5720 }
5721 if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5722 return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5723 }
5724 cursor += 4;
5725 if cursor != bytes.len() {
5726 return Err(invalid_feed("v2 bulk stream carries trailing data"));
5727 }
5728 Ok(result)
5729}
5730
5731fn download_v2_bulk_stream(
5732 cfg: &HubConfig,
5733 brain: &str,
5734 pointer: &V2PointerBody,
5735 pending: &[(&String, &V2BaselineFile)],
5736) -> LinkResult<Vec<(String, Vec<u8>)>> {
5737 let claims = pending
5738 .iter()
5739 .map(|(path, file)| {
5740 Ok(json!({
5741 "path": path,
5742 "sha256": file.sha256,
5743 "bytes": file.bytes,
5744 "proof": file.proof.as_ref().ok_or_else(|| {
5745 invalid_feed("v2 manifest omitted a bulk-stream proof")
5746 })?,
5747 }))
5748 })
5749 .collect::<LinkResult<Vec<_>>>()?;
5750 let raw = request_raw_retryable_read(
5751 cfg,
5752 "POST",
5753 &format!("/api/hub/brains/{brain}/v2/stream"),
5754 Some(&json!({
5755 "commit": pointer.commit_hash,
5756 "files": claims,
5757 })),
5758 Auth::Required,
5759 V2_BULK_STREAM_RESPONSE_BYTES,
5760 )?;
5761 let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5762 parse_v2_bulk_stream(&body, pending)
5763}
5764
5765fn request_capped_retryable_read(
5766 cfg: &HubConfig,
5767 method: &str,
5768 path: &str,
5769 body: Option<&Value>,
5770 auth: Auth,
5771 max_response_bytes: u64,
5772) -> LinkResult<HubResponse> {
5773 let raw = request_raw_retryable_read(cfg, method, path, body, auth, max_response_bytes)?;
5774 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
5775 Ok(HubResponse {
5776 status: raw.status,
5777 body: parsed,
5778 })
5779}
5780
5781fn prepare_v2_downloads(
5782 cfg: &HubConfig,
5783 brain: &str,
5784 pointer: &V2PointerBody,
5785 pending: &[(&String, &V2BaselineFile)],
5786) -> LinkResult<Vec<V2DownloadItem>> {
5787 let mut result = Vec::with_capacity(pending.len());
5788 for chunk in pending.chunks(128) {
5789 let claims = chunk
5790 .iter()
5791 .map(|(path, file)| {
5792 Ok(json!({
5793 "path": path,
5794 "sha256": file.sha256,
5795 "bytes": file.bytes,
5796 "proof": file.proof.as_ref().ok_or_else(|| {
5797 invalid_feed("v2 manifest omitted a download proof")
5798 })?,
5799 }))
5800 })
5801 .collect::<LinkResult<Vec<_>>>()?;
5802 let value = ensure_ok(
5803 request_capped_retryable_read(
5804 cfg,
5805 "POST",
5806 &format!("/api/hub/brains/{brain}/v2/downloads"),
5807 Some(&json!({
5808 "commit": pointer.commit_hash,
5809 "files": claims,
5810 })),
5811 Auth::Required,
5812 MAX_FEED_RESPONSE_BYTES,
5813 )?,
5814 "prepare v2 blob downloads",
5815 )?;
5816 let window: V2DownloadWindow = serde_json::from_value(value)
5817 .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5818 if window.v != 2
5819 || window.commit != pointer.commit_hash
5820 || window.downloads.len() != chunk.len()
5821 {
5822 return Err(invalid_feed(
5823 "v2 download window is not bound to the requested files",
5824 ));
5825 }
5826 let mut by_path = window
5827 .downloads
5828 .into_iter()
5829 .map(|item| (item.path.clone(), item))
5830 .collect::<std::collections::BTreeMap<_, _>>();
5831 if by_path.len() != chunk.len() {
5832 return Err(invalid_feed("v2 download window repeats a path"));
5833 }
5834 for (path, file) in chunk {
5835 let item = by_path
5836 .remove(*path)
5837 .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5838 if item.method != "GET"
5839 || item.sha256 != file.sha256
5840 || item.bytes != file.bytes
5841 || item.url.is_empty()
5842 {
5843 return Err(invalid_feed(
5844 "v2 download capability differs from its proven file",
5845 ));
5846 }
5847 result.push(item);
5848 }
5849 }
5850 Ok(result)
5851}
5852
5853fn prepare_v2_asset_downloads(
5854 cfg: &HubConfig,
5855 brain: &str,
5856 pointer: &V2PointerBody,
5857 pending: &[(&String, &V2BaselineAsset)],
5858) -> LinkResult<Vec<V2DownloadItem>> {
5859 let mut result = Vec::with_capacity(pending.len());
5860 for chunk in pending.chunks(128) {
5861 let claims = chunk
5862 .iter()
5863 .map(|(path, asset)| {
5864 json!({
5865 "path": path,
5866 "sha256": asset.blob_sha256,
5867 "bytes": asset.bytes,
5868 "leaf_hash": asset.leaf_hash,
5869 })
5870 })
5871 .collect::<Vec<_>>();
5872 let value = ensure_ok(
5873 request_capped_retryable_read(
5874 cfg,
5875 "POST",
5876 &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5877 Some(&json!({
5878 "commit": pointer.commit_hash,
5879 "assets": claims,
5880 })),
5881 Auth::Required,
5882 MAX_FEED_RESPONSE_BYTES,
5883 )?,
5884 "prepare v2 asset downloads",
5885 )?;
5886 let window: V2DownloadWindow = serde_json::from_value(value)
5887 .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5888 if window.v != 2
5889 || window.commit != pointer.commit_hash
5890 || window.downloads.len() != chunk.len()
5891 {
5892 return Err(invalid_feed(
5893 "v2 asset download window is not bound to the requested assets",
5894 ));
5895 }
5896 let mut by_path = window
5897 .downloads
5898 .into_iter()
5899 .map(|item| (item.path.clone(), item))
5900 .collect::<std::collections::BTreeMap<_, _>>();
5901 if by_path.len() != chunk.len() {
5902 return Err(invalid_feed("v2 asset download window repeats a path"));
5903 }
5904 for (path, asset) in chunk {
5905 let item = by_path
5906 .remove(*path)
5907 .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5908 if item.method != "GET"
5909 || item.sha256 != asset.blob_sha256
5910 || item.bytes != asset.bytes
5911 || item.url.is_empty()
5912 {
5913 return Err(invalid_feed(
5914 "v2 asset download capability differs from its signed leaf",
5915 ));
5916 }
5917 result.push(item);
5918 }
5919 }
5920 Ok(result)
5921}
5922
5923#[cfg(any(unix, windows))]
5924fn stage_v2_asset_download_window(
5925 cfg: &HubConfig,
5926 brain: &str,
5927 pointer: &V2PointerBody,
5928 cache_dir: &Path,
5929 pending: &[(&String, &V2BaselineAsset)],
5930) -> LinkResult<Vec<V2StagedFile>> {
5931 if pending.is_empty() {
5932 return Ok(Vec::new());
5933 }
5934 if pending.len() > V2_DOWNLOAD_CAPABILITY_FILES {
5935 return Err(invalid_feed("v2 asset capability window is oversized"));
5936 }
5937
5938 let mut last_error = None;
5939 for retry_delay in V2_DOWNLOAD_CAPABILITY_BACKOFF_MS
5940 .iter()
5941 .copied()
5942 .map(Some)
5943 .chain(std::iter::once(None))
5944 {
5945 let downloads = prepare_v2_asset_downloads(cfg, brain, pointer, pending)?;
5950 let mut unique = std::collections::BTreeMap::<String, V2DownloadItem>::new();
5951 for item in downloads {
5952 match unique.get(&item.sha256) {
5953 Some(prior) if prior.bytes != item.bytes => {
5954 return Err(invalid_feed(
5955 "one v2 asset hash has conflicting byte lengths",
5956 ));
5957 }
5958 Some(_) => {}
5959 None => {
5960 unique.insert(item.sha256.clone(), item);
5961 }
5962 }
5963 }
5964 let downloads = unique.into_values().collect::<Vec<_>>();
5965 let next = std::sync::atomic::AtomicUsize::new(0);
5966 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5967 let mut results = std::iter::repeat_with(|| None)
5968 .take(downloads.len())
5969 .collect::<Vec<Option<LinkResult<PathBuf>>>>();
5970 std::thread::scope(|scope| {
5971 let (sender, receiver) = std::sync::mpsc::channel();
5972 for _ in 0..worker_count {
5973 let sender = sender.clone();
5974 let downloads = &downloads;
5975 let next = &next;
5976 scope.spawn(move || loop {
5977 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5978 let Some(item) = downloads.get(index) else {
5979 break;
5980 };
5981 let result = download_presigned_to_cache(
5982 cfg,
5983 &item.url,
5984 cache_dir,
5985 &item.sha256,
5986 item.bytes,
5987 );
5988 if sender.send((index, result)).is_err() {
5989 break;
5990 }
5991 });
5992 }
5993 drop(sender);
5994 for (index, result) in receiver {
5995 results[index] = Some(result);
5996 }
5997 });
5998
5999 let mut failed = None;
6000 for result in results {
6001 match result {
6002 Some(Ok(_)) => {}
6003 Some(Err(error)) if failed.is_none() => failed = Some(error),
6004 Some(Err(_)) => {}
6005 None if failed.is_none() => {
6006 failed = Some(LinkError::Transport {
6007 hub: cfg.hub.clone(),
6008 message: "a bounded v2 asset worker stopped before reporting its result"
6009 .to_string(),
6010 });
6011 }
6012 None => {}
6013 }
6014 }
6015 if let Some(error) = failed {
6016 last_error = Some(error);
6017 if let Some(milliseconds) = retry_delay {
6018 std::thread::sleep(std::time::Duration::from_millis(milliseconds));
6019 continue;
6020 }
6021 break;
6022 }
6023
6024 return pending
6025 .iter()
6026 .map(|(path, asset)| {
6027 let source = cache_dir.join(&asset.blob_sha256);
6028 if !cached_blob_is_exact(&source, &asset.blob_sha256, asset.bytes)? {
6029 return Err(invalid_feed(
6030 "v2 asset download cache omitted a proven blob",
6031 ));
6032 }
6033 Ok(V2StagedFile {
6034 path: (*path).clone(),
6035 source,
6036 sha256: asset.blob_sha256.clone(),
6037 bytes: asset.bytes,
6038 })
6039 })
6040 .collect();
6041 }
6042 Err(last_error
6043 .unwrap_or_else(|| invalid_feed("v2 asset capability window made no download attempt")))
6044}
6045
6046fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
6047 let bytes = get_presigned(cfg, &item.url)?;
6048 if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
6049 return Err(invalid_feed("v2 blob differs from its proven path entry"));
6050 }
6051 Ok(bytes)
6052}
6053
6054#[derive(Debug, Clone)]
6055struct V2StagedFile {
6056 path: String,
6057 source: PathBuf,
6058 sha256: String,
6059 bytes: u64,
6060}
6061
6062#[cfg(unix)]
6063fn v2_download_cache_dir(
6064 cfg: &HubConfig,
6065 brain: &str,
6066 pointer: &V2PointerBody,
6067) -> LinkResult<PathBuf> {
6068 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
6069}
6070
6071#[cfg(unix)]
6072fn v2_download_cache_dir_for(
6073 cfg: &HubConfig,
6074 brain: &str,
6075 transaction: &str,
6076) -> LinkResult<PathBuf> {
6077 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6078 return Err(invalid_feed("v2 download cache address is invalid"));
6079 }
6080 let path = cfg
6081 .state_dir
6082 .join("downloads")
6083 .join(brain)
6084 .join(transaction);
6085 let directory = open_or_create_dir_nofollow(&path)?;
6086 use std::os::fd::AsRawFd as _;
6087 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
6088 return Err(std::io::Error::last_os_error().into());
6089 }
6090 directory.sync_all()?;
6091 Ok(path)
6092}
6093
6094#[cfg(windows)]
6095fn v2_download_cache_dir(
6096 cfg: &HubConfig,
6097 brain: &str,
6098 pointer: &V2PointerBody,
6099) -> LinkResult<PathBuf> {
6100 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
6101}
6102
6103#[cfg(windows)]
6104fn v2_download_cache_dir_for(
6105 cfg: &HubConfig,
6106 brain: &str,
6107 transaction: &str,
6108) -> LinkResult<PathBuf> {
6109 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6110 return Err(invalid_feed("v2 download cache address is invalid"));
6111 }
6112 let path = cfg
6113 .state_dir
6114 .join("downloads")
6115 .join(brain)
6116 .join(transaction);
6117 crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
6118 crate::fsx::open_directory_nofollow(&path)?;
6119 Ok(path)
6120}
6121
6122#[cfg(unix)]
6123fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
6124 use std::os::fd::AsRawFd as _;
6125 let parent = cfg.state_dir.join("downloads").join(brain);
6126 let Ok(directory) = open_existing_dir_nofollow(&parent) else {
6127 return;
6128 };
6129 let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
6130 return;
6131 };
6132 let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
6133 let _ = directory.sync_all();
6134}
6135
6136#[cfg(windows)]
6137fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
6138 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6139 return;
6140 }
6141 let parent = cfg.state_dir.join("downloads").join(brain);
6142 let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
6143 return;
6144 };
6145 let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
6146}
6147
6148#[cfg(not(any(unix, windows)))]
6149fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
6150
6151#[cfg(not(any(unix, windows)))]
6152fn v2_download_cache_dir_for(
6153 _cfg: &HubConfig,
6154 _brain: &str,
6155 _transaction: &str,
6156) -> LinkResult<PathBuf> {
6157 Err(LinkError::UnsupportedPlatform {
6158 operation: "resumable v2 download staging",
6159 })
6160}
6161
6162#[cfg(any(unix, windows))]
6163fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
6164 let file = match crate::fsx::open_regular_nofollow(path) {
6165 Ok(file) => file,
6166 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6167 Err(error) => return Err(error.into()),
6168 };
6169 if file.metadata()?.len() != bytes {
6170 return Ok(false);
6171 }
6172 Ok(content_sha256_reader(file)? == sha256)
6173}
6174
6175#[cfg(any(unix, windows))]
6176fn cache_v2_blob_bytes(
6177 cache_dir: &Path,
6178 sha256: &str,
6179 expected_bytes: u64,
6180 bytes: &[u8],
6181) -> LinkResult<PathBuf> {
6182 if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
6183 return Err(invalid_feed("v2 cached blob differs from its declaration"));
6184 }
6185 let path = cache_dir.join(sha256);
6186 if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
6187 crate::fsx::write_atomic(&path, bytes)?;
6188 }
6189 Ok(path)
6190}
6191
6192#[cfg(not(any(unix, windows)))]
6193fn cache_v2_blob_bytes(
6194 _cache_dir: &Path,
6195 _sha256: &str,
6196 _expected_bytes: u64,
6197 _bytes: &[u8],
6198) -> LinkResult<PathBuf> {
6199 Err(LinkError::UnsupportedPlatform {
6200 operation: "resumable v2 download staging",
6201 })
6202}
6203
6204#[cfg(unix)]
6205fn download_presigned_to_cache(
6206 cfg: &HubConfig,
6207 url: &str,
6208 cache_dir: &Path,
6209 sha256: &str,
6210 expected_bytes: u64,
6211) -> LinkResult<PathBuf> {
6212 use std::os::fd::{AsRawFd as _, FromRawFd as _};
6213
6214 let target = cache_dir.join(sha256);
6215 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6216 return Ok(target);
6217 }
6218 let directory = open_existing_dir_nofollow(cache_dir)?;
6219 let mut nonce = [0_u8; 16];
6220 ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
6221 .map_err(|_| invalid_feed("could not mint a download cache name"))?;
6222 let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
6223 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
6224 let fd = unsafe {
6225 libc::openat(
6226 directory.as_raw_fd(),
6227 temp.as_ptr(),
6228 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6229 0o600,
6230 )
6231 };
6232 if fd < 0 {
6233 return Err(std::io::Error::last_os_error().into());
6234 }
6235 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
6236 let response = match presigned_agent(cfg, url)?.get(url).call() {
6237 Ok(response) => response,
6238 Err(ureq::Error::Status(_, response)) => {
6239 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6240 return Err(LinkError::Http {
6241 what: "v2 direct download",
6242 status: response.status(),
6243 message: "object store rejected the download".to_string(),
6244 code: None,
6245 details: None,
6246 });
6247 }
6248 Err(ureq::Error::Transport(error)) => {
6249 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6250 return Err(LinkError::Transport {
6251 hub: cfg.hub.clone(),
6252 message: error.to_string(),
6253 });
6254 }
6255 };
6256 let mut reader = response
6257 .into_reader()
6258 .take(expected_bytes.saturating_add(1));
6259 let mut digest = Sha256::new();
6260 let mut total = 0_u64;
6261 let mut buffer = [0_u8; 64 * 1024];
6262 let write_result = (|| -> LinkResult<()> {
6267 loop {
6268 let read = reader
6269 .read(&mut buffer)
6270 .map_err(|error| LinkError::Transport {
6271 hub: cfg.hub.clone(),
6272 message: error.to_string(),
6273 })?;
6274 if read == 0 {
6275 break;
6276 }
6277 total = total.saturating_add(read as u64);
6278 digest.update(&buffer[..read]);
6279 output.write_all(&buffer[..read])?;
6280 }
6281 output.sync_all().map_err(LinkError::from)
6282 })();
6283 if let Err(error) = write_result {
6284 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6285 return Err(error);
6286 }
6287 drop(output);
6288 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6289 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6290 return Err(invalid_feed(
6291 "v2 direct download failed integrity verification",
6292 ));
6293 }
6294 let target_name = c_name(sha256.as_bytes(), sha256)?;
6295 if unsafe {
6298 libc::renameat(
6299 directory.as_raw_fd(),
6300 temp.as_ptr(),
6301 directory.as_raw_fd(),
6302 target_name.as_ptr(),
6303 )
6304 } != 0
6305 {
6306 let error = std::io::Error::last_os_error();
6307 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6308 return Err(error.into());
6309 }
6310 directory.sync_all()?;
6311 Ok(target)
6312}
6313
6314#[cfg(windows)]
6315fn download_presigned_to_cache(
6316 cfg: &HubConfig,
6317 url: &str,
6318 cache_dir: &Path,
6319 sha256: &str,
6320 expected_bytes: u64,
6321) -> LinkResult<PathBuf> {
6322 use std::fs::OpenOptions;
6323
6324 let target = cache_dir.join(sha256);
6325 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6326 return Ok(target);
6327 }
6328 let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
6332 let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
6333 let mut output = OpenOptions::new()
6334 .write(true)
6335 .create_new(true)
6336 .open(&temp)?;
6337 let response = match presigned_agent(cfg, url)?.get(url).call() {
6338 Ok(response) => response,
6339 Err(ureq::Error::Status(_, response)) => {
6340 let _ = std::fs::remove_file(&temp);
6341 return Err(LinkError::Http {
6342 what: "v2 direct download",
6343 status: response.status(),
6344 message: "object store rejected the download".to_string(),
6345 code: None,
6346 details: None,
6347 });
6348 }
6349 Err(ureq::Error::Transport(error)) => {
6350 let _ = std::fs::remove_file(&temp);
6351 return Err(LinkError::Transport {
6352 hub: cfg.hub.clone(),
6353 message: error.to_string(),
6354 });
6355 }
6356 };
6357 let mut reader = response
6358 .into_reader()
6359 .take(expected_bytes.saturating_add(1));
6360 let mut digest = Sha256::new();
6361 let mut total = 0_u64;
6362 let mut buffer = [0_u8; 64 * 1024];
6363 let copied = (|| -> LinkResult<()> {
6365 loop {
6366 let read = reader
6367 .read(&mut buffer)
6368 .map_err(|error| LinkError::Transport {
6369 hub: cfg.hub.clone(),
6370 message: error.to_string(),
6371 })?;
6372 if read == 0 {
6373 break;
6374 }
6375 total = total.saturating_add(read as u64);
6376 digest.update(&buffer[..read]);
6377 output.write_all(&buffer[..read])?;
6378 }
6379 output.sync_all()?;
6380 Ok(())
6381 })();
6382 if let Err(error) = copied {
6383 let _ = std::fs::remove_file(&temp);
6384 return Err(error);
6385 }
6386 drop(output);
6387 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6388 let _ = std::fs::remove_file(&temp);
6389 return Err(invalid_feed(
6390 "v2 direct download failed integrity verification",
6391 ));
6392 }
6393 if target.exists() {
6394 std::fs::remove_file(&target)?;
6395 }
6396 if let Err(error) = std::fs::rename(&temp, &target) {
6397 let _ = std::fs::remove_file(&temp);
6398 return Err(error.into());
6399 }
6400 Ok(target)
6401}
6402
6403#[cfg(not(any(unix, windows)))]
6404fn download_presigned_to_cache(
6405 _cfg: &HubConfig,
6406 _url: &str,
6407 _cache_dir: &Path,
6408 _sha256: &str,
6409 _expected_bytes: u64,
6410) -> LinkResult<PathBuf> {
6411 Err(LinkError::UnsupportedPlatform {
6412 operation: "resumable v2 download staging",
6413 })
6414}
6415
6416fn download_v2_blobs(
6417 cfg: &HubConfig,
6418 brain: &str,
6419 pointer: &V2PointerBody,
6420 pending: Vec<(&String, &V2BaselineFile)>,
6421) -> LinkResult<Vec<(String, Vec<u8>)>> {
6422 if pending.is_empty() {
6423 return Ok(Vec::new());
6424 }
6425 let expected_order = pending
6426 .iter()
6427 .map(|(path, _)| (*path).clone())
6428 .collect::<Vec<_>>();
6429 let mut streamed = std::collections::BTreeMap::new();
6430 let mut direct = Vec::new();
6431 let mut window = Vec::new();
6432 let mut window_bytes = 0_u64;
6433 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6434 window_bytes: &mut u64,
6435 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
6436 -> LinkResult<()> {
6437 if window.is_empty() {
6438 return Ok(());
6439 }
6440 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
6441 if streamed.insert(path, bytes).is_some() {
6442 return Err(invalid_feed("v2 bulk streams repeated a path"));
6443 }
6444 }
6445 window.clear();
6446 *window_bytes = 0;
6447 Ok(())
6448 };
6449 for &(path, file) in &pending {
6450 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6451 flush(&mut window, &mut window_bytes, &mut streamed)?;
6452 direct.push((path, file));
6453 continue;
6454 }
6455 if window.len() == V2_BULK_STREAM_FILES
6456 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6457 {
6458 flush(&mut window, &mut window_bytes, &mut streamed)?;
6459 }
6460 window.push((path, file));
6461 window_bytes += file.bytes;
6462 }
6463 flush(&mut window, &mut window_bytes, &mut streamed)?;
6464
6465 let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
6466 let next = std::sync::atomic::AtomicUsize::new(0);
6467 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6468 let mut results = std::iter::repeat_with(|| None)
6469 .take(downloads.len())
6470 .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
6471 std::thread::scope(|scope| {
6472 let (sender, receiver) = std::sync::mpsc::channel();
6473 for _ in 0..worker_count {
6474 let sender = sender.clone();
6475 let downloads = &downloads;
6476 let next = &next;
6477 scope.spawn(move || loop {
6478 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6479 let Some(item) = downloads.get(index) else {
6480 break;
6481 };
6482 let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
6483 if sender.send((index, result)).is_err() {
6484 break;
6485 }
6486 });
6487 }
6488 drop(sender);
6489 for (index, result) in receiver {
6490 results[index] = Some(result);
6491 }
6492 });
6493 for result in results.into_iter().map(|result| {
6494 result.ok_or_else(|| LinkError::Transport {
6495 hub: cfg.hub.clone(),
6496 message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
6497 })?
6498 }) {
6499 let (path, bytes) = result?;
6500 if streamed.insert(path, bytes).is_some() {
6501 return Err(invalid_feed("v2 download lanes repeated a path"));
6502 }
6503 }
6504 expected_order
6505 .into_iter()
6506 .map(|path| {
6507 streamed
6508 .remove(&path)
6509 .map(|bytes| (path, bytes))
6510 .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
6511 })
6512 .collect()
6513}
6514
6515#[cfg(any(unix, windows))]
6519fn queue_v2_bulk_window<'a>(
6520 cache_dir: &Path,
6521 window: &mut Vec<(&'a String, &'a V2BaselineFile)>,
6522 window_bytes: &mut u64,
6523 staged: &mut std::collections::BTreeMap<String, V2StagedFile>,
6524 missing_windows: &mut Vec<Vec<(&'a String, &'a V2BaselineFile)>>,
6525) -> LinkResult<()> {
6526 if window.is_empty() {
6527 return Ok(());
6528 }
6529 let missing = window
6530 .iter()
6531 .filter_map(|(path, file)| {
6532 let target = cache_dir.join(&file.sha256);
6533 match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6534 Ok(true) => {
6535 staged.insert(
6536 (*path).clone(),
6537 V2StagedFile {
6538 path: (*path).clone(),
6539 source: target,
6540 sha256: file.sha256.clone(),
6541 bytes: file.bytes,
6542 },
6543 );
6544 None
6545 }
6546 Ok(false) => Some(Ok((*path, *file))),
6547 Err(error) => Some(Err(error)),
6548 }
6549 })
6550 .collect::<LinkResult<Vec<_>>>()?;
6551 if !missing.is_empty() {
6552 missing_windows.push(missing);
6553 }
6554 window.clear();
6555 *window_bytes = 0;
6556 Ok(())
6557}
6558
6559#[cfg(any(unix, windows))]
6560fn stage_v2_bulk_windows<'a>(
6561 cfg: &HubConfig,
6562 brain: &str,
6563 pointer: &V2PointerBody,
6564 cache_dir: &Path,
6565 windows: Vec<Vec<(&'a String, &'a V2BaselineFile)>>,
6566 staged: &mut std::collections::BTreeMap<String, V2StagedFile>,
6567) -> LinkResult<()> {
6568 let next = std::sync::atomic::AtomicUsize::new(0);
6569 let worker_count = windows.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6570 let mut first_error = None;
6571 std::thread::scope(|scope| {
6572 let (sender, receiver) = std::sync::mpsc::channel();
6573 for _ in 0..worker_count {
6574 let sender = sender.clone();
6575 let windows = &windows;
6576 let next = &next;
6577 scope.spawn(move || loop {
6578 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6579 let Some(window) = windows.get(index) else {
6580 break;
6581 };
6582 let result = download_v2_bulk_stream(cfg, brain, pointer, window);
6583 if sender.send((index, result)).is_err() {
6584 break;
6585 }
6586 });
6587 }
6588 drop(sender);
6589 for (index, result) in receiver {
6590 match result {
6591 Ok(files) => {
6592 for (path, bytes) in files {
6593 let Some(file) = windows[index].iter().find_map(|(expected_path, file)| {
6594 (*expected_path == &path).then_some(*file)
6595 }) else {
6596 first_error.get_or_insert_with(|| {
6597 invalid_feed("v2 stream returned an unrequested cache path")
6598 });
6599 continue;
6600 };
6601 match cache_v2_blob_bytes(cache_dir, &file.sha256, file.bytes, &bytes) {
6602 Ok(source) => {
6603 if staged
6604 .insert(
6605 path.clone(),
6606 V2StagedFile {
6607 path,
6608 source,
6609 sha256: file.sha256.clone(),
6610 bytes: file.bytes,
6611 },
6612 )
6613 .is_some()
6614 {
6615 first_error.get_or_insert_with(|| {
6616 invalid_feed("v2 bulk streams repeated a path")
6617 });
6618 }
6619 }
6620 Err(error) => {
6621 first_error.get_or_insert(error);
6622 }
6623 }
6624 }
6625 }
6626 Err(error) => {
6627 first_error.get_or_insert(error);
6628 }
6629 }
6630 }
6631 });
6632 match first_error {
6633 Some(error) => Err(error),
6634 None => Ok(()),
6635 }
6636}
6637
6638#[cfg(any(unix, windows))]
6639fn stage_v2_blobs(
6640 cfg: &HubConfig,
6641 brain: &str,
6642 pointer: &V2PointerBody,
6643 pending: Vec<(&String, &V2BaselineFile)>,
6644) -> LinkResult<Vec<V2StagedFile>> {
6645 let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6646 let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6647 let mut direct = Vec::new();
6648 let mut window = Vec::new();
6649 let mut missing_windows = Vec::new();
6650 let mut window_bytes = 0_u64;
6651 for &(path, file) in &pending {
6652 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6653 queue_v2_bulk_window(
6654 &cache_dir,
6655 &mut window,
6656 &mut window_bytes,
6657 &mut staged,
6658 &mut missing_windows,
6659 )?;
6660 direct.push((path, file));
6661 continue;
6662 }
6663 if window.len() == V2_BULK_STREAM_FILES
6664 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6665 {
6666 queue_v2_bulk_window(
6667 &cache_dir,
6668 &mut window,
6669 &mut window_bytes,
6670 &mut staged,
6671 &mut missing_windows,
6672 )?;
6673 }
6674 window.push((path, file));
6675 window_bytes += file.bytes;
6676 }
6677 queue_v2_bulk_window(
6678 &cache_dir,
6679 &mut window,
6680 &mut window_bytes,
6681 &mut staged,
6682 &mut missing_windows,
6683 )?;
6684 stage_v2_bulk_windows(
6685 cfg,
6686 brain,
6687 pointer,
6688 &cache_dir,
6689 missing_windows,
6690 &mut staged,
6691 )?;
6692 for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6693 let source =
6694 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6695 staged.insert(
6696 item.path.clone(),
6697 V2StagedFile {
6698 path: item.path,
6699 source,
6700 sha256: item.sha256,
6701 bytes: item.bytes,
6702 },
6703 );
6704 }
6705 pending
6706 .into_iter()
6707 .map(|(path, _)| {
6708 staged
6709 .remove(path)
6710 .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6711 })
6712 .collect()
6713}
6714
6715#[cfg(not(any(unix, windows)))]
6716fn stage_v2_blobs(
6717 _cfg: &HubConfig,
6718 _brain: &str,
6719 _pointer: &V2PointerBody,
6720 _pending: Vec<(&String, &V2BaselineFile)>,
6721) -> LinkResult<Vec<V2StagedFile>> {
6722 Err(LinkError::UnsupportedPlatform {
6723 operation: "resumable v2 download staging",
6724 })
6725}
6726
6727const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6728const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6729const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6730
6731#[derive(Debug, Clone, Deserialize, Serialize)]
6732struct V2ConflictCoordinate {
6733 sha256: Option<String>,
6734 bytes: Option<u64>,
6735 file: Option<String>,
6736}
6737
6738#[derive(Debug, Clone, Deserialize, Serialize)]
6739struct V2ConflictFile {
6740 path: String,
6741 base: V2ConflictCoordinate,
6742 local: V2ConflictCoordinate,
6743 remote: V2ConflictCoordinate,
6744}
6745
6746#[derive(Debug, Clone, Deserialize, Serialize)]
6747struct V2ConflictPlan {
6748 v: u8,
6749 class: String,
6750 bundle: String,
6751 brain: String,
6752 origin: String,
6753 created_unix: u64,
6754 expires_unix: u64,
6755 base_seq: Option<u64>,
6756 base_commit: Option<String>,
6757 remote_seq: u64,
6758 remote_commit: Option<String>,
6759 remote_content_root: Option<String>,
6760 view_kind: String,
6761 view_revision: String,
6762 files: Vec<V2ConflictFile>,
6763}
6764
6765fn v2_take_remote_selection(
6766 files: &[V2ConflictFile],
6767 current: &std::collections::BTreeMap<String, V2BaselineFile>,
6768) -> LinkResult<(
6769 std::collections::BTreeMap<String, V2BaselineFile>,
6770 Vec<String>,
6771)> {
6772 let mut selected = std::collections::BTreeMap::new();
6773 let mut deleted = Vec::new();
6774 for file in files {
6775 match (&file.remote.sha256, file.remote.bytes) {
6776 (Some(sha256), Some(bytes)) => {
6777 let proven = current.get(&file.path).ok_or_else(|| {
6778 invalid_feed("conflict remote coordinate disappeared from the exact head")
6779 })?;
6780 if proven.sha256 != *sha256 || proven.bytes != bytes {
6781 return Err(invalid_feed(
6782 "conflict remote coordinate differs from the exact head",
6783 ));
6784 }
6785 if selected.insert(file.path.clone(), proven.clone()).is_some() {
6786 return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6787 }
6788 }
6789 (None, None) => {
6790 if current.contains_key(&file.path) {
6791 return Err(invalid_feed(
6792 "conflict remote deletion differs from the exact head",
6793 ));
6794 }
6795 deleted.push(file.path.clone());
6796 }
6797 _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6798 }
6799 }
6800 Ok((selected, deleted))
6801}
6802
6803fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6804 PathBuf::from(".dbmd")
6805 .join("conflicts")
6806 .join(bundle)
6807 .join(suffix)
6808}
6809
6810fn read_historical_conflict_blob(
6811 cfg: &HubConfig,
6812 brain: &str,
6813 baseline: &V2SyncBaseline,
6814 path: &str,
6815 file: &V2BaselineFile,
6816) -> LinkResult<Option<Vec<u8>>> {
6817 let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6818 return Ok(None);
6819 };
6820 if seq == 0 {
6821 return Ok(None);
6822 }
6823 let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6824 let endpoint = format!(
6825 "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6826 file.sha256
6827 );
6828 let history_http = hub_agent_with_timeout(cfg, std::time::Duration::from_secs(15))?;
6829 let raw = match request_raw_with_agent(
6830 cfg,
6831 &history_http,
6832 "GET",
6833 &endpoint,
6834 None,
6835 RawRequestOptions {
6836 auth: Auth::Required,
6837 max_response_bytes: file.bytes,
6838 request_id: None,
6839 retry_transport: false,
6840 },
6841 ) {
6842 Ok(raw) => raw,
6843 Err(LinkError::Transport { .. }) => return Ok(None),
6848 Err(error) => return Err(error),
6849 };
6850 if raw.status == 404 || raw.status == 403 {
6851 return Ok(None);
6852 }
6853 let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6854 if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6855 return Err(invalid_feed(
6856 "v2 conflict base failed integrity verification",
6857 ));
6858 }
6859 Ok(Some(bytes))
6860}
6861
6862fn create_v2_conflict_bundle(
6865 cfg: &HubConfig,
6866 store: &Store,
6867 head: &V2VerifiedHead,
6868 baseline: Option<&V2SyncBaseline>,
6869 local: &std::collections::BTreeMap<String, (String, u64)>,
6870 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6871 paths: &[String],
6872) -> LinkResult<(String, Vec<String>)> {
6873 let conflicts_root = Path::new(".dbmd/conflicts");
6874 store.create_dir_all(conflicts_root)?;
6875 let completed = store
6876 .directory_names(conflicts_root)?
6877 .into_iter()
6878 .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6879 .count();
6880 if completed >= V2_CONFLICT_BUNDLE_MAX {
6881 return Err(LinkError::InvalidPack {
6882 message: format!(
6883 "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6884 ),
6885 });
6886 }
6887
6888 let mut selected_paths = Vec::new();
6892 let mut selected_remote_bytes = 0_u64;
6893 for path in paths {
6894 let bytes = remote.get(path).map_or(0, |file| file.bytes);
6895 if !selected_paths.is_empty()
6896 && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6897 {
6898 break;
6899 }
6900 selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6901 selected_paths.push(path.clone());
6902 if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6903 break;
6904 }
6905 }
6906 if selected_paths.is_empty() {
6907 return Err(invalid_feed("content conflict set is empty"));
6908 }
6909 let bundle = crate::ulid::mint();
6910 let bundle_root = v2_conflict_relative(&bundle, "");
6911 store.create_dir_all(&bundle_root.join("files"))?;
6912 let pointer = head.pointer.as_ref();
6913 let remote_bytes = match pointer {
6914 Some(pointer) => download_v2_blobs(
6915 cfg,
6916 &head.brain_id,
6917 pointer,
6918 selected_paths
6919 .iter()
6920 .filter_map(|path| {
6921 remote
6922 .get(path)
6923 .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6924 .map(|file| (path, file))
6925 })
6926 .collect(),
6927 )?
6928 .into_iter()
6929 .collect::<std::collections::BTreeMap<_, _>>(),
6930 None => std::collections::BTreeMap::new(),
6931 };
6932
6933 let mut files = Vec::with_capacity(selected_paths.len());
6934 let mut historical_body_available = true;
6935 for (index, path) in selected_paths.iter().enumerate() {
6936 let base_file = baseline.and_then(|state| state.files.get(path));
6937 let base_bytes = match (historical_body_available, baseline, base_file) {
6938 (true, Some(state), Some(file)) => {
6939 let bytes = read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?;
6940 if bytes.is_none() {
6941 historical_body_available = false;
6946 }
6947 bytes
6948 }
6949 _ => None,
6950 };
6951 let local_file = local.get(path);
6952 let remote_file = remote.get(path);
6953 let remote_content = remote_bytes.get(path);
6954 let prefix = format!("files/{index:04}");
6955 let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6956 let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6957 let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6958 if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6959 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6960 }
6961 if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6962 let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6963 if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6964 return Err(LinkError::InvalidPack {
6965 message: format!("local conflict path `{path}` changed while bundling"),
6966 });
6967 }
6968 store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6969 }
6970 if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6971 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6972 }
6973 files.push(V2ConflictFile {
6974 path: path.clone(),
6975 base: V2ConflictCoordinate {
6976 sha256: base_file.map(|file| file.sha256.clone()),
6977 bytes: base_file.map(|file| file.bytes),
6978 file: base_name,
6979 },
6980 local: V2ConflictCoordinate {
6981 sha256: local_file.map(|(sha256, _)| sha256.clone()),
6982 bytes: local_file.map(|(_, bytes)| *bytes),
6983 file: local_name,
6984 },
6985 remote: V2ConflictCoordinate {
6986 sha256: remote_file.map(|file| file.sha256.clone()),
6987 bytes: remote_file.map(|file| file.bytes),
6988 file: remote_name,
6989 },
6990 });
6991 }
6992 let now = SystemTime::now()
6993 .duration_since(UNIX_EPOCH)
6994 .unwrap_or_default()
6995 .as_secs();
6996 let plan = V2ConflictPlan {
6997 v: 2,
6998 class: "content_resolution_required".to_string(),
6999 bundle: bundle.clone(),
7000 brain: head.brain_id.clone(),
7001 origin: normalized_origin(&cfg.hub)?,
7002 created_unix: now,
7003 expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
7004 base_seq: baseline.and_then(|state| state.head_seq),
7005 base_commit: baseline.and_then(|state| state.commit_hash.clone()),
7006 remote_seq: pointer.map_or(0, |value| value.seq),
7007 remote_commit: pointer.map(|value| value.commit_hash.clone()),
7008 remote_content_root: pointer.and_then(|value| value.content_root.clone()),
7009 view_kind: head.view_kind.clone(),
7010 view_revision: head.view_revision.clone(),
7011 files,
7012 };
7013 let mut bytes = serde_json::to_vec_pretty(&plan)
7014 .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
7015 bytes.push(b'\n');
7016 store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
7017 Ok((bundle, selected_paths))
7018}
7019
7020fn v2_sync_pull_with_resolution(
7021 cfg: &HubConfig,
7022 requested_brain: &str,
7023 expected_head: V2VerifiedHead,
7024 out: Option<&Path>,
7025 take_remote: Option<&std::collections::BTreeSet<String>>,
7026) -> LinkResult<V2PulledSnapshot> {
7027 let dest = out
7028 .map(Path::to_path_buf)
7029 .unwrap_or_else(|| PathBuf::from(requested_brain));
7030 let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
7031 recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
7032 let head = v2_verified_head(cfg, requested_brain)?
7033 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7034 if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
7035 return Err(LinkError::RemoteAdvancedDuringSync);
7036 }
7037 let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
7038 ensure_v2_view_compatible(&head, baseline.as_ref())?;
7039 let (remote, remote_assets) = match baseline
7040 .as_ref()
7041 .filter(|state| v2_baseline_matches_head(&head, state))
7042 {
7043 Some(state) => (state.files.clone(), state.assets.clone()),
7044 None => (
7045 files_for_v2_view(
7046 &head,
7047 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7048 ),
7049 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7050 ),
7051 };
7052 let local_store = Store::open_strict(&dest).ok();
7053 ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
7058 let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
7059 if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
7060 return Err(LinkError::ScopedViewChanged);
7061 }
7062 if let Some(view) = local_view.as_mut() {
7063 remove_scoped_projection(&head, baseline.as_ref(), view)?;
7064 }
7065 let empty_local = std::collections::BTreeMap::new();
7066 let local = local_view
7067 .as_ref()
7068 .map_or(&empty_local, |view| &view.riding);
7069 let kept_home = |path: &str| {
7070 local_view
7071 .as_ref()
7072 .is_some_and(|view| view.policy.keeps_home(path))
7073 };
7074 let empty_base = std::collections::BTreeMap::new();
7075 let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
7076 let empty_base_assets = std::collections::BTreeMap::new();
7077 let base_assets = baseline
7078 .as_ref()
7079 .map_or(&empty_base_assets, |state| &state.assets);
7080 let mut local_assets = local_store
7081 .as_ref()
7082 .map(v2_local_asset_records)
7083 .transpose()?
7084 .unwrap_or_default();
7085 let mut content_merge = merge_v2_pulled_records(
7086 base,
7087 &remote,
7088 local,
7089 |file, _| (file.sha256.clone(), file.bytes),
7090 |file, _| (file.sha256.clone(), file.bytes),
7091 kept_home,
7092 );
7093 if let Some(selected) = take_remote {
7094 for path in selected {
7095 if let Some(position) = content_merge
7096 .conflicts
7097 .iter()
7098 .position(|conflict| conflict == path)
7099 {
7100 content_merge.conflicts.remove(position);
7101 content_merge.accept_remote.insert(path.clone());
7102 match remote.get(path) {
7103 Some(file) => {
7104 content_merge
7105 .records
7106 .insert(path.clone(), (file.sha256.clone(), file.bytes));
7107 }
7108 None => {
7109 content_merge.records.remove(path);
7110 }
7111 }
7112 } else if !content_merge.accept_remote.contains(path) {
7113 return Err(LinkError::InvalidPack {
7114 message: format!(
7115 "take-remote path `{path}` is no longer at its conflict coordinate"
7116 ),
7117 });
7118 }
7119 }
7120 }
7121 if !content_merge.conflicts.is_empty() {
7122 let mut conflicts = content_merge.conflicts.clone();
7123 conflicts.truncate(100);
7124 if let Some(store) = local_store.as_ref() {
7125 let (bundle, paths) = create_v2_conflict_bundle(
7126 cfg,
7127 store,
7128 &head,
7129 baseline.as_ref(),
7130 local,
7131 &remote,
7132 &conflicts,
7133 )?;
7134 return Err(LinkError::ConflictBundle { bundle, paths });
7135 }
7136 return Err(LinkError::Conflict { paths: conflicts });
7137 }
7138 let asset_merge = merge_v2_pulled_records(
7139 base_assets,
7140 &remote_assets,
7141 &local_assets,
7142 v2_asset_record,
7143 v2_asset_record,
7144 |_| false,
7145 );
7146 if !asset_merge.conflicts.is_empty() {
7147 let mut conflicts = asset_merge.conflicts.clone();
7148 conflicts.truncate(100);
7149 return Err(LinkError::Conflict { paths: conflicts });
7150 }
7151 let pointer = head.pointer.as_ref();
7152 let cache_transaction = pointer.map_or_else(
7153 || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
7154 |value| value.commit_hash.clone(),
7155 );
7156 let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
7157 let mut changed = match pointer {
7158 Some(pointer) => stage_v2_blobs(
7159 cfg,
7160 &head.brain_id,
7161 pointer,
7162 remote
7163 .iter()
7164 .filter(|(path, file)| {
7165 content_merge.accept_remote.contains(*path)
7166 && local.get(*path).map(|value| value.0.as_str())
7167 != Some(file.sha256.as_str())
7168 })
7169 .collect(),
7170 )?,
7171 None => Vec::new(),
7172 };
7173 let mut deleted = content_merge
7174 .accept_remote
7175 .iter()
7176 .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
7177 .cloned()
7178 .collect::<Vec<_>>();
7179 if local_assets != asset_merge.records {
7180 if asset_merge.records.is_empty() {
7181 deleted.push("assets.jsonl".to_string());
7182 } else {
7183 let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
7184 let sha256 = content_sha256(&bytes);
7185 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
7186 changed.push(V2StagedFile {
7187 path: "assets.jsonl".to_string(),
7188 source,
7189 sha256,
7190 bytes: bytes.len() as u64,
7191 });
7192 }
7193 }
7194 if let Some(pointer) = pointer {
7195 let mut pending_assets = Vec::new();
7196 for (path, asset) in &remote_assets {
7197 if asset.disposition != "hosted"
7198 || kept_home(path)
7199 || !asset_merge.accept_remote.contains(path)
7200 {
7201 continue;
7202 }
7203 let already_current = local_store.as_ref().is_some_and(|store| {
7204 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7205 && store
7206 .read_bounded(Path::new(path), asset.bytes)
7207 .ok()
7208 .is_some_and(|bytes| {
7209 bytes.len() as u64 == asset.bytes
7210 && content_sha256(&bytes) == asset.blob_sha256
7211 })
7212 });
7213 if !already_current {
7214 pending_assets.push((path, asset));
7215 }
7216 }
7217 let mut window = Vec::new();
7218 let mut window_bytes = 0_u64;
7219 let flush = |window: &mut Vec<(&String, &V2BaselineAsset)>,
7220 window_bytes: &mut u64,
7221 changed: &mut Vec<V2StagedFile>|
7222 -> LinkResult<()> {
7223 changed.extend(stage_v2_asset_download_window(
7224 cfg,
7225 &head.brain_id,
7226 pointer,
7227 &cache_dir,
7228 window,
7229 )?);
7230 window.clear();
7231 *window_bytes = 0;
7232 Ok(())
7233 };
7234 for item @ (_, asset) in pending_assets {
7235 if !window.is_empty()
7236 && (window.len() == V2_DOWNLOAD_CAPABILITY_FILES
7237 || window_bytes.saturating_add(asset.bytes) > V2_DOWNLOAD_CAPABILITY_BYTES)
7238 {
7239 flush(&mut window, &mut window_bytes, &mut changed)?;
7240 }
7241 window.push(item);
7242 window_bytes = window_bytes.saturating_add(asset.bytes);
7243 if window_bytes >= V2_DOWNLOAD_CAPABILITY_BYTES {
7244 flush(&mut window, &mut window_bytes, &mut changed)?;
7245 }
7246 }
7247 flush(&mut window, &mut window_bytes, &mut changed)?;
7248 }
7249 for (path, prior) in base_assets {
7250 if remote_assets.contains_key(path)
7251 || kept_home(path)
7252 || !asset_merge.accept_remote.contains(path)
7253 {
7254 continue;
7255 }
7256 let unchanged = local_store.as_ref().is_some_and(|store| {
7257 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7258 && store
7259 .read_bounded(Path::new(path), prior.bytes)
7260 .ok()
7261 .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
7262 });
7263 if unchanged {
7264 deleted.push(path.clone());
7265 }
7266 }
7267 let extra_local = content_merge
7268 .records
7269 .keys()
7270 .filter(|path| !remote.contains_key(*path))
7271 .cloned()
7272 .collect::<Vec<_>>();
7273 if head.view_kind == "scoped" {
7274 for (path, bytes) in [
7275 ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
7276 (
7277 ".dbmd/view.json".to_string(),
7278 scoped_view_metadata(&head, remote.len())?,
7279 ),
7280 ] {
7281 let sha256 = content_sha256(&bytes);
7282 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
7283 changed.push(V2StagedFile {
7284 path,
7285 source,
7286 sha256,
7287 bytes: bytes.len() as u64,
7288 });
7289 }
7290 }
7291 let install_changed = !changed.is_empty() || !deleted.is_empty();
7292 install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
7293 let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
7294 let installed_store =
7295 Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
7296 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
7297 })?;
7298 let installed_local = if install_changed {
7299 let hint = baseline.as_ref().and_then(|state| {
7300 state
7301 .local_policy_digest
7302 .as_deref()
7303 .map(|digest| (digest, &state.scan_cache))
7304 });
7305 let mut scanned = v2_local_files_cached(&installed_store, hint)?;
7306 remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
7307 scanned
7308 } else {
7309 local_view
7310 .take()
7311 .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
7312 };
7313 if installed_local.riding != content_merge.records {
7314 return Err(LinkError::InvalidPack {
7315 message: "local content changed while installing the v2 pull".to_string(),
7316 });
7317 }
7318 let installed_assets = if install_changed {
7319 v2_local_asset_records(&installed_store)?
7320 } else {
7321 std::mem::take(&mut local_assets)
7322 };
7323 if installed_assets != asset_merge.records {
7324 return Err(LinkError::InvalidPack {
7325 message: "local assets changed while installing the v2 pull".to_string(),
7326 });
7327 }
7328 let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
7329 installed_local.policy.keeps_home(path)
7330 })
7331 || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
7332 let final_head = v2_verified_head(cfg, requested_brain)?
7333 .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
7334 if !same_v2_head(&head, &final_head) {
7335 return Err(LinkError::RemoteAdvancedDuringSync);
7336 }
7337 accept_v2_head(cfg, &final_head)?;
7338 save_v2_baseline(
7339 cfg,
7340 &head.brain_id,
7341 &dest,
7342 &v2_baseline_from_head(
7343 cfg,
7344 &head,
7345 remote.clone(),
7346 remote_assets.clone(),
7347 Some(&installed_local),
7348 baseline
7349 .as_ref()
7350 .and_then(|current| current.checkout_id.as_deref()),
7351 )?,
7352 )?;
7353 complete_v2_pull(&dest)?;
7354 Ok((local_dirty, installed_local, installed_assets))
7355 })();
7356 let (local_dirty, installed_local, installed_assets) = match finalized {
7357 Ok(value) => value,
7358 Err(error) => {
7359 if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
7360 return Err(LinkError::InvalidPack {
7361 message: format!("{error}; durable pull recovery also failed: {recovery}"),
7362 });
7363 }
7364 return Err(error);
7365 }
7366 };
7367 cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
7368 let report = PullReport {
7369 brain: head.brain_id.clone(),
7370 slug: requested_brain.to_string(),
7371 head_seq: pointer.map_or(0, |value| value.seq),
7372 files: remote.len() + remote_assets.len(),
7373 dest: dest.to_string_lossy().into_owned(),
7374 extra_local,
7375 sync_status: if local_dirty {
7376 "local_dirty_after_install".to_string()
7377 } else {
7378 "synced".to_string()
7379 },
7380 };
7381 Ok(V2PulledSnapshot {
7382 report,
7383 head,
7384 files: remote,
7385 assets: remote_assets,
7386 local: installed_local,
7387 local_assets: installed_assets,
7388 })
7389}
7390
7391fn v2_sync_pull(
7392 cfg: &HubConfig,
7393 requested_brain: &str,
7394 head: V2VerifiedHead,
7395 out: Option<&Path>,
7396) -> LinkResult<PullReport> {
7397 Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
7398}
7399
7400fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
7401 match remote {
7402 Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
7403 None => json!({ "kind": "absent" }),
7404 }
7405}
7406
7407fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
7408 match remote {
7409 Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
7410 None => json!({ "kind": "absent" }),
7411 }
7412}
7413
7414fn v2_content_withdrawal_operation(
7415 store: &Store,
7416 local_view: &V2LocalView,
7417 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7418 path: &str,
7419 reason: &str,
7420) -> LinkResult<Value> {
7421 if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
7422 || path == "DB.md"
7423 {
7424 return Err(LinkError::InvalidPack {
7425 message: format!("content withdrawal path `{path}` is not a record or source"),
7426 });
7427 }
7428 if !local_view.policy.keeps_home(path)
7429 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7430 {
7431 return Err(LinkError::InvalidPack {
7432 message: format!(
7433 "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7434 ),
7435 });
7436 }
7437 let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
7438 message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
7439 })?;
7440 verify_v2_upload_source(store, path, ¤t.sha256, current.bytes)?;
7441 Ok(json!({
7442 "op": "withdraw_from_hosting",
7443 "path": path,
7444 "expected": { "kind": "blob", "hash": current.sha256 },
7445 "reason": reason,
7446 }))
7447}
7448
7449fn v2_asset_withdrawal_operation(
7450 store: &Store,
7451 local_view: &V2LocalView,
7452 path: &str,
7453 local: &crate::AssetRecord,
7454 current: &V2BaselineAsset,
7455 reason: &str,
7456) -> LinkResult<Value> {
7457 if !local_view.policy.keeps_home(path)
7458 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7459 {
7460 return Err(LinkError::InvalidPack {
7461 message: format!(
7462 "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7463 ),
7464 });
7465 }
7466 verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
7467 if current.disposition != "hosted"
7468 || current.blob_sha256 != local.sha256
7469 || current.bytes != local.bytes
7470 || current.media_type != local.media_type
7471 {
7472 return Err(LinkError::InvalidPack {
7473 message: format!(
7474 "asset withdrawal path `{path}` must preserve the currently hosted blob identity, byte count, and media type"
7475 ),
7476 });
7477 }
7478 Ok(json!({
7479 "op": "asset_withdraw",
7480 "path": path,
7481 "expected": v2_asset_expected(Some(current)),
7482 "asset": v2_asset_value(local, "withheld"),
7483 "reason": reason,
7484 }))
7485}
7486
7487fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
7494 let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
7495 let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
7496 for (index, operation) in operations.iter().enumerate() {
7497 match operation.get("op").and_then(Value::as_str) {
7498 Some("delete") => {
7499 let Some(path) = operation.get("path").and_then(Value::as_str) else {
7500 continue;
7501 };
7502 let Some(hash) = operation
7503 .get("expected")
7504 .and_then(|value| value.get("hash"))
7505 .and_then(Value::as_str)
7506 else {
7507 continue;
7508 };
7509 if path.starts_with("sources/") {
7510 deletes
7511 .entry(hash.to_string())
7512 .or_default()
7513 .push((index, path.to_string()));
7514 }
7515 }
7516 Some("put") => {
7517 let Some(path) = operation.get("path").and_then(Value::as_str) else {
7518 continue;
7519 };
7520 let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
7521 continue;
7522 };
7523 let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
7524 continue;
7525 };
7526 let destination_absent = operation
7527 .get("expected")
7528 .and_then(|value| value.get("kind"))
7529 .and_then(Value::as_str)
7530 == Some("absent");
7531 if path.starts_with("sources/") && destination_absent {
7532 puts.entry(hash.to_string()).or_default().push((
7533 index,
7534 path.to_string(),
7535 bytes,
7536 ));
7537 }
7538 }
7539 _ => {}
7540 }
7541 }
7542 let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
7543 let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
7544 for (hash, source) in deletes {
7545 let Some(destination) = puts.get(&hash) else {
7546 continue;
7547 };
7548 if source.len() != 1 || destination.len() != 1 {
7549 continue;
7550 }
7551 let (delete_index, from) = &source[0];
7552 let (put_index, to, bytes) = &destination[0];
7553 if from == to {
7554 continue;
7555 }
7556 rename_at.insert(
7557 *delete_index,
7558 json!({
7559 "op": "rename",
7560 "from": from,
7561 "to": to,
7562 "expected_from": { "kind": "blob", "hash": hash },
7563 "expected_to": { "kind": "absent" },
7564 "blob": hash,
7565 "bytes": bytes,
7566 }),
7567 );
7568 consumed_puts.insert(*put_index);
7569 }
7570 operations
7571 .into_iter()
7572 .enumerate()
7573 .filter_map(|(index, operation)| {
7574 if let Some(rename) = rename_at.remove(&index) {
7575 Some(rename)
7576 } else if consumed_puts.contains(&index) {
7577 None
7578 } else {
7579 Some(operation)
7580 }
7581 })
7582 .collect()
7583}
7584
7585fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
7586 json!({
7587 "blob_sha256": record.sha256,
7588 "bytes": record.bytes,
7589 "media_type": record.media_type,
7590 "wrappers": record.wrappers,
7591 "required": record.required,
7592 "disposition": disposition,
7593 })
7594}
7595
7596fn apply_generated_v2_operations(
7600 operations: &[Value],
7601 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
7602 candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
7603 candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
7604) -> LinkResult<bool> {
7605 let mut asset_changed = false;
7606 for operation in operations {
7607 match operation.get("op").and_then(Value::as_str) {
7608 Some("put") => {
7609 let path = operation
7610 .get("path")
7611 .and_then(Value::as_str)
7612 .ok_or_else(|| invalid_feed("v2 put has no path"))?;
7613 let sha256 = operation
7614 .get("blob")
7615 .and_then(Value::as_str)
7616 .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
7617 let bytes = operation
7618 .get("bytes")
7619 .and_then(Value::as_u64)
7620 .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
7621 candidate.insert(
7622 path.to_string(),
7623 V2BaselineFile {
7624 sha256: sha256.to_string(),
7625 bytes,
7626 proof: None,
7627 },
7628 );
7629 }
7630 Some("rename") => {
7631 let from = operation
7632 .get("from")
7633 .and_then(Value::as_str)
7634 .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
7635 let to = operation
7636 .get("to")
7637 .and_then(Value::as_str)
7638 .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
7639 let sha256 = operation
7640 .get("blob")
7641 .and_then(Value::as_str)
7642 .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
7643 let bytes = operation
7644 .get("bytes")
7645 .and_then(Value::as_u64)
7646 .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
7647 let expected_from = operation
7648 .get("expected_from")
7649 .and_then(|expected| expected.get("hash"))
7650 .and_then(Value::as_str);
7651 let expected_to_absent = operation
7652 .get("expected_to")
7653 .and_then(|expected| expected.get("kind"))
7654 .and_then(Value::as_str)
7655 == Some("absent");
7656 if from == to
7657 || !from.starts_with("sources/")
7658 || !to.starts_with("sources/")
7659 || expected_from != Some(sha256)
7660 || !expected_to_absent
7661 || candidate.contains_key(to)
7662 {
7663 return Err(invalid_feed("generated v2 source rename is malformed"));
7664 }
7665 let source = candidate
7666 .remove(from)
7667 .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
7668 if source.sha256 != sha256 || source.bytes != bytes {
7669 return Err(invalid_feed(
7670 "v2 rename source differs from its exact-byte claim",
7671 ));
7672 }
7673 candidate.insert(
7674 to.to_string(),
7675 V2BaselineFile {
7676 sha256: sha256.to_string(),
7677 bytes,
7678 proof: None,
7679 },
7680 );
7681 }
7682 Some("delete" | "withdraw_from_hosting") => {
7683 let path = operation
7684 .get("path")
7685 .and_then(Value::as_str)
7686 .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7687 candidate.remove(path);
7688 }
7689 Some("asset_delete") => {
7690 let path = operation
7691 .get("path")
7692 .and_then(Value::as_str)
7693 .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7694 candidate_assets.remove(path);
7695 asset_changed = true;
7696 }
7697 Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7698 let path = operation
7699 .get("path")
7700 .and_then(Value::as_str)
7701 .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7702 let record = local_assets
7703 .get(path)
7704 .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7705 let disposition =
7706 if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7707 "withheld"
7708 } else {
7709 operation
7710 .get("asset")
7711 .and_then(|asset| asset.get("disposition"))
7712 .and_then(Value::as_str)
7713 .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7714 };
7715 candidate_assets.insert(
7716 path.to_string(),
7717 V2BaselineAsset {
7718 blob_sha256: record.sha256.clone(),
7719 bytes: record.bytes,
7720 media_type: record.media_type.clone(),
7721 wrappers: record.wrappers.clone(),
7722 required: record.required,
7723 disposition: disposition.to_string(),
7724 leaf_hash: String::new(),
7727 },
7728 );
7729 asset_changed = true;
7730 }
7731 _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7732 }
7733 }
7734 Ok(asset_changed)
7735}
7736
7737fn v2_riding_matches_remote(
7738 local: &std::collections::BTreeMap<String, (String, u64)>,
7739 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7740 keeps_home: impl Fn(&str) -> bool,
7741) -> bool {
7742 remote.iter().all(|(path, file)| {
7743 keeps_home(path)
7744 || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7745 }) && local.iter().all(|(path, (hash, _))| {
7746 remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7747 })
7748}
7749
7750fn v2_initial_content_conflicts(
7751 local: &std::collections::BTreeMap<String, (String, u64)>,
7752 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7753 resolving: bool,
7754) -> Vec<String> {
7755 if resolving {
7756 return Vec::new();
7764 }
7765 remote
7766 .iter()
7767 .filter(|(path, file)| {
7768 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7769 })
7770 .map(|(path, _)| path.clone())
7771 .collect()
7772}
7773
7774fn v2_resolution_allows_path(
7775 resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7776 path: &str,
7777 remote_present: bool,
7778) -> bool {
7779 resolution.is_none_or(|allowed| allowed.contains_key(path) || !remote_present)
7780}
7781
7782fn v2_resolution_allows_asset(
7783 resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7784 base: Option<&V2BaselineAsset>,
7785 remote: Option<&V2BaselineAsset>,
7786 local: Option<&crate::AssetRecord>,
7787) -> bool {
7788 let Some(allowed) = resolution else {
7789 return false;
7790 };
7791 let wrappers = base
7792 .into_iter()
7793 .flat_map(|asset| asset.wrappers.iter())
7794 .chain(remote.into_iter().flat_map(|asset| asset.wrappers.iter()))
7795 .chain(local.into_iter().flat_map(|asset| asset.wrappers.iter()))
7796 .collect::<BTreeSet<_>>();
7797 !wrappers.is_empty()
7798 && wrappers
7799 .iter()
7800 .all(|wrapper| allowed.contains_key(*wrapper))
7801}
7802
7803#[derive(Debug, Clone)]
7804struct V2ResolutionOverride {
7805 expected_remote: Option<String>,
7806 selected_local: Option<String>,
7807}
7808
7809#[derive(Debug, Clone)]
7810struct V2UploadSource {
7811 path: String,
7812 bytes: u64,
7813}
7814
7815struct V2SyncPushOptions<'a> {
7816 resume_local_policy: bool,
7817 bulk_confirmation: Option<&'a V2BulkConfirmation>,
7818 resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7819 pulled: Option<V2PulledSnapshot>,
7820 withdrawal_paths: &'a [String],
7821 withdrawal_reason: Option<&'a str>,
7822 allow_contract_phase: bool,
7827}
7828
7829fn verify_v2_upload_source(
7830 store: &Store,
7831 path: &str,
7832 sha256: &str,
7833 expected_bytes: u64,
7834) -> LinkResult<()> {
7835 let file = store.open_regular(Path::new(path))?;
7836 if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7837 return Err(LinkError::InvalidPack {
7838 message: format!("local path `{path}` changed during sync planning"),
7839 });
7840 }
7841 Ok(())
7842}
7843
7844struct V2PendingUpload<'a> {
7847 url: String,
7848 headers: Value,
7849 sha256: String,
7850 source: &'a V2UploadSource,
7851}
7852
7853const V2_UPLOAD_CONCURRENCY: usize = 16;
7860
7861fn upload_v2_batch_concurrently(
7865 cfg: &HubConfig,
7866 store: &Store,
7867 pending: &[V2PendingUpload<'_>],
7868) -> LinkResult<()> {
7869 if pending.is_empty() {
7870 return Ok(());
7871 }
7872 let urls = pending
7873 .iter()
7874 .map(|task| task.url.as_str())
7875 .collect::<Vec<_>>();
7876 let shared = shared_staging_agent(cfg, &urls);
7877 if pending.len() == 1 {
7878 let task = &pending[0];
7879 put_presigned_source(
7880 cfg,
7881 &task.url,
7882 &task.headers,
7883 store,
7884 task.source,
7885 shared.as_ref(),
7886 )?;
7887 return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7888 }
7889 let next = std::sync::atomic::AtomicUsize::new(0);
7890 let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7891 let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7892 std::thread::scope(|scope| {
7893 for _ in 0..workers {
7894 scope.spawn(|| loop {
7895 if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7896 return;
7897 }
7898 let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7899 let Some(task) = pending.get(index) else {
7900 return;
7901 };
7902 let outcome = put_presigned_source(
7903 cfg,
7904 &task.url,
7905 &task.headers,
7906 store,
7907 task.source,
7908 shared.as_ref(),
7909 )
7910 .and_then(|()| {
7911 verify_v2_upload_source(
7912 store,
7913 &task.source.path,
7914 &task.sha256,
7915 task.source.bytes,
7916 )
7917 });
7918 if let Err(error) = outcome {
7919 if let Ok(mut guard) = failure.lock() {
7920 guard.get_or_insert(error);
7921 }
7922 return;
7923 }
7924 });
7925 }
7926 });
7927 match failure.into_inner() {
7928 Ok(Some(error)) => Err(error),
7929 Ok(None) => Ok(()),
7930 Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7931 }
7932}
7933
7934fn put_presigned_source(
7935 cfg: &HubConfig,
7936 raw: &str,
7937 headers: &Value,
7938 store: &Store,
7939 source: &V2UploadSource,
7940 shared: Option<&ureq::Agent>,
7941) -> LinkResult<()> {
7942 put_presigned_source_with_budget(
7943 cfg,
7944 raw,
7945 headers,
7946 store,
7947 source,
7948 shared,
7949 std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
7950 )
7951}
7952
7953fn put_presigned_source_with_budget(
7954 cfg: &HubConfig,
7955 raw: &str,
7956 headers: &Value,
7957 store: &Store,
7958 source: &V2UploadSource,
7959 shared: Option<&ureq::Agent>,
7960 total_budget: std::time::Duration,
7961) -> LinkResult<()> {
7962 let owned = match shared {
7965 Some(_) => {
7966 checked_presigned_url(cfg, raw)?;
7967 None
7968 }
7969 None => Some(presigned_agent(cfg, raw)?),
7970 };
7971 let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7972 let deadline = std::time::Instant::now()
7973 .checked_add(total_budget)
7974 .ok_or_else(upload_deadline_error)?;
7975 let mut attempt = 0;
7976 let result = loop {
7977 let file = store.open_regular(Path::new(&source.path))?;
7978 if file.metadata()?.len() != source.bytes {
7979 return Err(LinkError::InvalidPack {
7980 message: format!("local path `{}` changed before upload", source.path),
7981 });
7982 }
7983 let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
7988 let mut has_content_length = false;
7989 if let Some(map) = headers.as_object() {
7990 for (name, value) in map {
7991 if let Some(value) = value.as_str() {
7992 has_content_length |= name.eq_ignore_ascii_case("content-length");
7993 req = req.set(name, value);
7994 }
7995 }
7996 }
7997 if !has_content_length {
7998 req = req.set("Content-Length", &source.bytes.to_string());
7999 }
8000 match req.send(file) {
8001 Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
8007 attempt += 1;
8008 }
8009 Err(ureq::Error::Status(status, _))
8015 if status != 412
8016 && is_retryable_upload_status(status)
8017 && wait_for_upload_retry(deadline, attempt) =>
8018 {
8019 attempt += 1;
8020 }
8021 result => break result,
8022 }
8023 };
8024 match result {
8025 Ok(response) if (200..300).contains(&response.status()) => {
8026 drain_presigned_response(response);
8027 Ok(())
8028 }
8029 Ok(response) => {
8030 let status = response.status();
8035 let detail = response
8036 .into_string()
8037 .ok()
8038 .map(|body| body.chars().take(400).collect::<String>())
8039 .filter(|body| !body.trim().is_empty());
8040 Err(LinkError::Http {
8041 what: "v2 changed-byte upload",
8042 status,
8043 message: match detail {
8044 Some(body) => format!(
8045 "object store rejected the upload of `{}`: {}",
8046 source.path,
8047 body.replace('\n', " ")
8048 ),
8049 None => format!("object store rejected the upload of `{}`", source.path),
8050 },
8051 code: None,
8052 details: None,
8053 })
8054 }
8055 Err(error) => match error {
8056 ureq::Error::Status(412, _) => Ok(()),
8057 ureq::Error::Status(_, response) => {
8058 let status = response.status();
8059 let detail = response
8060 .into_string()
8061 .ok()
8062 .map(|body| body.chars().take(400).collect::<String>())
8063 .filter(|body| !body.trim().is_empty());
8064 Err(LinkError::Http {
8065 what: "v2 changed-byte upload",
8066 status,
8067 message: match detail {
8068 Some(body) => format!(
8069 "object store rejected the upload of `{}`: {}",
8070 source.path,
8071 body.replace('\n', " ")
8072 ),
8073 None => {
8074 format!("object store rejected the upload of `{}`", source.path)
8075 }
8076 },
8077 code: None,
8078 details: None,
8079 })
8080 }
8081 ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
8082 },
8083 }
8084}
8085
8086fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
8090 if body.get("operations").is_some() {
8091 return body.clone();
8092 }
8093 let mut value = body.clone();
8094 if let Some(map) = value.as_object_mut() {
8095 map.remove("staged_change");
8096 map.insert("operations".to_string(), Value::Array(operations.to_vec()));
8097 }
8098 value
8099}
8100
8101fn reserve_upload_window(
8105 cfg: &HubConfig,
8106 path: &str,
8107 body: &Value,
8108 what: &'static str,
8109) -> LinkResult<Value> {
8110 let mut attempt = 0;
8111 loop {
8112 let pause = |attempt: usize| {
8113 std::thread::sleep(std::time::Duration::from_millis(
8114 RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
8115 ));
8116 };
8117 match request(cfg, "POST", path, Some(body), Auth::Required) {
8118 Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
8123 pause(attempt);
8124 attempt += 1;
8125 }
8126 Err(error) => return Err(error),
8127 Ok(response) => {
8128 if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
8129 pause(attempt);
8130 attempt += 1;
8131 continue;
8132 }
8133 return ensure_ok(response, what);
8134 }
8135 }
8136 }
8137}
8138
8139fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
8143 let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
8144 .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
8145 if bytes.len() > MAX_STAGED_CHANGE_BYTES {
8146 return Err(LinkError::PushTooLarge {
8147 detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
8148 });
8149 }
8150 Ok(bytes)
8151}
8152
8153fn stage_v2_change(
8163 cfg: &HubConfig,
8164 requested_brain: &str,
8165 operations: &[Value],
8166 blobs: Value,
8167) -> LinkResult<Value> {
8168 let bytes = v2_change_manifest(operations, blobs)?;
8169 let sha256 = content_sha256(&bytes);
8170 let reserved = reserve_upload_window(
8171 cfg,
8172 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8173 &json!({
8174 "blobs": [{
8175 "sha256": sha256,
8176 "bytes": bytes.len(),
8177 "kind": "staged_change",
8178 }],
8179 }),
8180 "stage the v2 change",
8181 )?;
8182 let items = reserved
8183 .get("uploads")
8184 .and_then(Value::as_array)
8185 .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
8186 let [item] = items.as_slice() else {
8187 return Err(invalid_feed(
8188 "v2 change staging response changed the requested set",
8189 ));
8190 };
8191 let reservation_id = item
8192 .get("reservation_id")
8193 .and_then(Value::as_str)
8194 .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
8195 if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
8196 || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
8197 || !crate::ulid::is_ulid(reservation_id)
8198 {
8199 return Err(invalid_feed("v2 change staging item is inconsistent"));
8200 }
8201 match item.get("status").and_then(Value::as_str) {
8202 Some("upload") => put_presigned(
8203 cfg,
8204 item.get("url")
8205 .and_then(Value::as_str)
8206 .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
8207 item.get("headers").unwrap_or(&Value::Null),
8208 &bytes,
8209 )?,
8210 Some("already_present") => {}
8211 _ => return Err(invalid_feed("v2 change staging has an unknown status")),
8212 }
8213 Ok(json!({
8214 "sha256": sha256,
8215 "bytes": bytes.len(),
8216 "reservation_id": reservation_id,
8217 }))
8218}
8219
8220fn stage_oversized_v2_change(
8224 cfg: &HubConfig,
8225 requested_brain: &str,
8226 operations: &[Value],
8227 body: &mut Value,
8228) -> LinkResult<()> {
8229 if body.to_string().len() <= MAX_PUSH_BYTES {
8230 return Ok(());
8231 }
8232 let staged = stage_v2_change(
8233 cfg,
8234 requested_brain,
8235 operations,
8236 body.get("blobs")
8237 .cloned()
8238 .unwrap_or(Value::Array(Vec::new())),
8239 )?;
8240 let map = body
8241 .as_object_mut()
8242 .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
8243 map.remove("operations");
8244 map.remove("blobs");
8245 map.insert("staged_change".to_string(), staged);
8246 Ok(())
8247}
8248
8249fn v2_sync_push(
8250 cfg: &HubConfig,
8251 requested_brain: &str,
8252 store: &Store,
8253 head: V2VerifiedHead,
8254 options: V2SyncPushOptions<'_>,
8255) -> LinkResult<Value> {
8256 let V2SyncPushOptions {
8257 resume_local_policy,
8258 bulk_confirmation,
8259 resolution,
8260 pulled,
8261 withdrawal_paths,
8262 withdrawal_reason,
8263 allow_contract_phase,
8264 } = options;
8265 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
8266 let head = v2_verified_head(cfg, requested_brain)?
8267 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
8268 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
8269 ensure_v2_view_compatible(&head, baseline.as_ref())?;
8270 let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
8271 match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
8272 Some(snapshot) => (
8273 snapshot.files,
8274 snapshot.assets,
8275 Some(snapshot.local),
8276 Some(snapshot.local_assets),
8277 ),
8278 None => match baseline
8279 .as_ref()
8280 .filter(|state| v2_baseline_matches_head(&head, state))
8281 {
8282 Some(state) => (state.files.clone(), state.assets.clone(), None, None),
8283 None => (
8284 files_for_v2_view(
8285 &head,
8286 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
8287 ),
8288 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
8289 None,
8290 None,
8291 ),
8292 },
8293 };
8294 if head.view_kind == "scoped" && baseline.is_none() {
8295 return Err(LinkError::ScopedViewChanged);
8296 }
8297 let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
8298 let local = &local_view.riding;
8299 let local_assets = match carried_local_assets {
8300 Some(assets) => assets,
8301 None => v2_local_asset_records(store)?,
8302 };
8303 if withdrawal_paths.len() > MAX_PUSH_FILES {
8304 return Err(LinkError::PushTooLarge {
8305 detail: "too many explicit withdrawal paths".to_string(),
8306 });
8307 }
8308 let withdrawal_reason = if withdrawal_paths.is_empty() {
8309 None
8310 } else {
8311 let reason = withdrawal_reason
8312 .map(str::trim)
8313 .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
8314 .ok_or_else(|| LinkError::InvalidPack {
8315 message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
8316 })?;
8317 Some(reason)
8318 };
8319 let mut withdrawals = withdrawal_paths
8320 .iter()
8321 .map(|path| {
8322 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
8323 path: error.to_string(),
8324 })
8325 })
8326 .collect::<LinkResult<Vec<_>>>()?;
8327 withdrawals.sort();
8328 withdrawals.dedup();
8329 if withdrawals.len() != withdrawal_paths.len() {
8330 return Err(LinkError::InvalidPack {
8331 message: "explicit withdrawal paths must be unique".to_string(),
8332 });
8333 }
8334 let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
8335 let mut consumed_withdrawals = BTreeSet::new();
8336 if let Some(previous) = baseline.as_ref() {
8337 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
8338 && !resume_local_policy
8339 {
8340 let mut newly_eligible = previous
8341 .local_eligibility
8342 .iter()
8343 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
8344 .map(|(path, _)| path.clone())
8345 .collect::<Vec<_>>();
8346 if !newly_eligible.is_empty() {
8347 newly_eligible.truncate(100);
8348 return Err(LinkError::LocalPolicyTransition {
8349 paths: newly_eligible,
8350 });
8351 }
8352 }
8353 }
8354 if baseline
8360 .as_ref()
8361 .is_some_and(|state| !v2_baseline_matches_head(&head, state))
8362 && resolution.is_none()
8363 && withdrawal_paths.is_empty()
8364 && v2_riding_matches_remote(local, &remote, |path| local_view.policy.keeps_home(path))
8365 && v2_asset_records_match_remote(&local_assets, &remote_assets)
8366 {
8367 let final_head = v2_verified_head(cfg, requested_brain)?
8368 .ok_or_else(|| invalid_feed("v2 head disappeared during baseline recovery"))?;
8369 if !same_v2_head(&head, &final_head) {
8370 return Err(LinkError::RemoteAdvancedDuringSync);
8371 }
8372 let mut final_local = v2_local_files_cached(
8373 store,
8374 Some((&local_view.policy.digest, &local_view.scan_cache)),
8375 )?;
8376 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8377 let final_assets = v2_local_asset_records(store)?;
8378 if final_local.riding != local_view.riding || final_assets != local_assets {
8379 return Err(LinkError::RemoteAdvancedDuringSync);
8380 }
8381 let checkout_pseudonym = v2_checkout_id(
8382 baseline
8383 .as_ref()
8384 .and_then(|current| current.checkout_id.as_deref()),
8385 )?;
8386 let next = v2_baseline_from_head(
8387 cfg,
8388 &head,
8389 remote,
8390 remote_assets,
8391 Some(&final_local),
8392 Some(&checkout_pseudonym),
8393 )?;
8394 let split_count = next.remote_copy_remains.len();
8395 accept_v2_head(cfg, &final_head)?;
8396 refresh_scoped_view_marker(store, &head, next.files.len())?;
8397 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8398 return Ok(json!({
8399 "v": 2,
8400 "outcome": "no_change",
8401 "sync_status": "synced",
8402 "baseline_recovered": true,
8403 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8404 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8405 "local_policy": {
8406 "remote_copy_remains": split_count,
8407 },
8408 }));
8409 }
8410 let base = match baseline.as_ref() {
8411 Some(state) => &state.files,
8412 None if remote.is_empty() => &remote,
8413 None => {
8414 let mut conflicts = v2_initial_content_conflicts(local, &remote, resolution.is_some());
8415 if !conflicts.is_empty() {
8416 conflicts.truncate(100);
8417 let (bundle, paths) =
8418 create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
8419 return Err(LinkError::ConflictBundle { bundle, paths });
8420 }
8421 &remote
8422 }
8423 };
8424 let all_paths = base
8425 .keys()
8426 .chain(remote.keys())
8427 .chain(local.keys())
8428 .cloned()
8429 .collect::<std::collections::BTreeSet<_>>();
8430 let mut conflicts = Vec::new();
8431 let mut operations = Vec::new();
8432 let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
8433 for path in all_paths {
8434 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
8435 let remote_file = remote.get(&path);
8436 let remote_hash = remote_file.map(|file| file.sha256.as_str());
8437 let local_file = local.get(&path);
8438 let local_hash = local_file.map(|file| file.0.as_str());
8439 if local_hash == remote_hash {
8444 continue;
8445 }
8446 if local_hash == base_hash {
8447 continue;
8448 }
8449 if !v2_resolution_allows_path(resolution, &path, remote_file.is_some()) {
8450 continue;
8451 }
8452 if local_view.policy.keeps_home(&path) {
8453 continue;
8456 }
8457 if remote_hash != base_hash && local_hash != remote_hash {
8458 let explicitly_resolved = resolution
8459 .and_then(|allowed| allowed.get(&path))
8460 .is_some_and(|selected| {
8461 selected.expected_remote.as_deref() == remote_hash
8462 && selected.selected_local.as_deref() == local_hash
8463 });
8464 if !explicitly_resolved {
8465 conflicts.push(path);
8466 continue;
8467 }
8468 }
8469 match local_file {
8470 Some((sha256, byte_count)) => {
8471 verify_v2_upload_source(store, &path, sha256, *byte_count)?;
8472 operations.push(json!({
8473 "op": "put",
8474 "path": path,
8475 "expected": v2_expected(remote_file),
8476 "blob": sha256,
8477 "bytes": byte_count,
8478 }));
8479 upload_sources
8480 .entry(sha256.clone())
8481 .or_insert_with(|| V2UploadSource {
8482 path: path.clone(),
8483 bytes: *byte_count,
8484 });
8485 }
8486 None => {
8487 let Some(current) = remote_file else {
8488 continue;
8489 };
8490 operations.push(json!({
8491 "op": "delete",
8492 "path": path,
8493 "expected": { "kind": "blob", "hash": current.sha256 },
8494 }));
8495 }
8496 }
8497 }
8498 operations = infer_exact_source_promotions(operations);
8499 for path in &withdrawals {
8500 if local_assets.contains_key(path) {
8501 continue;
8502 }
8503 operations.push(v2_content_withdrawal_operation(
8504 store,
8505 &local_view,
8506 &remote,
8507 path,
8508 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8509 )?);
8510 consumed_withdrawals.insert(path.clone());
8511 }
8512 if !conflicts.is_empty() {
8513 conflicts.truncate(100);
8514 let (bundle, paths) = create_v2_conflict_bundle(
8515 cfg,
8516 store,
8517 &head,
8518 baseline.as_ref(),
8519 local,
8520 &remote,
8521 &conflicts,
8522 )?;
8523 return Err(LinkError::ConflictBundle { bundle, paths });
8524 }
8525 let base_assets = match baseline.as_ref() {
8526 Some(state) => &state.assets,
8527 None if remote_assets.is_empty() => &remote_assets,
8528 None => {
8529 let mismatched = remote_assets
8530 .keys()
8531 .chain(local_assets.keys())
8532 .collect::<BTreeSet<_>>()
8533 .into_iter()
8534 .filter(|path| {
8535 remote_assets
8536 .get(*path)
8537 .map(|asset| v2_asset_record(asset, path))
8538 .as_ref()
8539 != local_assets.get(*path)
8540 })
8541 .collect::<Vec<_>>();
8542 let resolution_covers_all = !mismatched.is_empty()
8543 && mismatched.iter().all(|path| {
8544 v2_resolution_allows_asset(
8545 resolution,
8546 None,
8547 remote_assets.get(*path),
8548 local_assets.get(*path),
8549 )
8550 });
8551 if !mismatched.is_empty() && !resolution_covers_all {
8552 return Err(LinkError::Conflict {
8553 paths: vec!["assets.jsonl".to_string()],
8554 });
8555 }
8556 &remote_assets
8557 }
8558 };
8559 let asset_paths = base_assets
8560 .keys()
8561 .chain(remote_assets.keys())
8562 .chain(local_assets.keys())
8563 .cloned()
8564 .collect::<std::collections::BTreeSet<_>>();
8565 let mut asset_policy_transitions = Vec::new();
8566 let mut asset_withdrawal_transitions = Vec::new();
8567 for path in asset_paths {
8568 let base_record = base_assets
8569 .get(&path)
8570 .map(|asset| v2_asset_record(asset, &path));
8571 let remote = remote_assets.get(&path);
8572 let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
8573 let local_record = local_assets.get(&path);
8574 if withdrawal_set.contains(&path) {
8575 let record = local_record.ok_or_else(|| LinkError::InvalidPack {
8576 message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
8577 })?;
8578 let current = remote.ok_or_else(|| LinkError::InvalidPack {
8579 message: format!(
8580 "asset withdrawal path `{path}` has no readable hosted coordinate"
8581 ),
8582 })?;
8583 operations.push(v2_asset_withdrawal_operation(
8584 store,
8585 &local_view,
8586 &path,
8587 record,
8588 current,
8589 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8590 )?);
8591 consumed_withdrawals.insert(path.clone());
8592 continue;
8593 }
8594 let mut raw_present = false;
8595 let mut disposition = "withheld";
8596 let mut resumes_hosting = false;
8597 if let Some(record) = local_record {
8598 crate::linkmd_v2::normalize_path(&record.path)
8599 .map_err(|error| invalid_feed(error.to_string()))?;
8600 let kept_home = local_view.policy.keeps_home(&path);
8601 raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
8602 disposition = if kept_home || !raw_present {
8603 "withheld"
8604 } else {
8605 "hosted"
8606 };
8607 let inherits_withheld_absence = v2_asset_inherits_withheld_absence(
8608 base_assets.get(&path),
8609 base_record.as_ref(),
8610 local_record,
8611 raw_present,
8612 );
8613 if !raw_present && record.required && !kept_home && !inherits_withheld_absence {
8614 return Err(LinkError::InvalidPack {
8615 message: format!("required asset {path} is missing"),
8616 });
8617 }
8618 resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
8619 if remote.is_some_and(|asset| asset.disposition == "hosted")
8620 && disposition == "withheld"
8621 {
8622 asset_withdrawal_transitions.push(path.clone());
8623 continue;
8624 }
8625 }
8626 if local_record == base_record.as_ref() && !resumes_hosting {
8627 continue;
8628 }
8629 if remote_record != base_record
8630 && local_record != remote_record.as_ref()
8631 && !v2_resolution_allows_asset(resolution, base_assets.get(&path), remote, local_record)
8632 {
8633 conflicts.push(path);
8634 continue;
8635 }
8636 let Some(record) = local_record else {
8637 if let Some(remote) = remote {
8638 operations.push(json!({
8639 "op": "asset_delete",
8640 "path": path,
8641 "expected": v2_asset_expected(Some(remote)),
8642 }));
8643 }
8644 continue;
8645 };
8646 let raw = if raw_present {
8647 verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
8648 Some(())
8649 } else {
8650 None
8651 };
8652 let op = if resumes_hosting {
8653 if !resume_local_policy {
8654 asset_policy_transitions.push(path);
8655 continue;
8656 }
8657 "asset_resume"
8658 } else {
8659 "asset_put"
8660 };
8661 operations.push(json!({
8662 "op": op,
8663 "path": path,
8664 "expected": v2_asset_expected(remote),
8665 "asset": v2_asset_value(record, disposition),
8666 }));
8667 if disposition == "hosted" {
8668 raw.expect("hosted asset was checked present");
8669 upload_sources
8670 .entry(record.sha256.clone())
8671 .or_insert_with(|| V2UploadSource {
8672 path: path.clone(),
8673 bytes: record.bytes,
8674 });
8675 }
8676 }
8677 if consumed_withdrawals != withdrawal_set {
8678 let missing = withdrawal_set
8679 .difference(&consumed_withdrawals)
8680 .next()
8681 .expect("different withdrawal sets have one member");
8682 return Err(LinkError::InvalidPack {
8683 message: format!(
8684 "withdrawal path `{missing}` is not a readable content or asset coordinate"
8685 ),
8686 });
8687 }
8688 if !conflicts.is_empty() {
8689 conflicts.truncate(100);
8690 return Err(LinkError::Conflict { paths: conflicts });
8691 }
8692 if !asset_policy_transitions.is_empty() {
8693 asset_policy_transitions.truncate(100);
8694 return Err(LinkError::LocalPolicyTransition {
8695 paths: asset_policy_transitions,
8696 });
8697 }
8698 if !asset_withdrawal_transitions.is_empty() {
8699 asset_withdrawal_transitions.truncate(100);
8700 return Err(LinkError::AssetWithdrawalRequired {
8701 paths: asset_withdrawal_transitions,
8702 });
8703 }
8704 let contract_phase = operations.len() > 1
8705 && operations.iter().any(|operation| {
8706 operation.get("path").and_then(Value::as_str) == Some("DB.md")
8707 && !operation
8708 .get("op")
8709 .and_then(Value::as_str)
8710 .is_some_and(|kind| kind.starts_with("asset_"))
8711 });
8712 if contract_phase {
8713 if !allow_contract_phase {
8714 return Err(LinkError::RemoteAdvancedDuringSync);
8715 }
8716 operations.retain(|operation| {
8717 operation.get("path").and_then(Value::as_str) == Some("DB.md")
8718 && !operation
8719 .get("op")
8720 .and_then(Value::as_str)
8721 .is_some_and(|kind| kind.starts_with("asset_"))
8722 });
8723 let contract_blobs = operations
8724 .iter()
8725 .filter_map(|operation| operation.get("blob").and_then(Value::as_str))
8726 .collect::<BTreeSet<_>>();
8727 upload_sources.retain(|sha256, _| contract_blobs.contains(sha256.as_str()));
8728 }
8729 let touched_sources = operations
8730 .iter()
8731 .filter_map(
8732 |operation| match operation.get("op").and_then(Value::as_str) {
8733 Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
8734 Some("rename") => operation.get("to").and_then(Value::as_str),
8735 _ => None,
8736 },
8737 )
8738 .collect::<std::collections::BTreeSet<_>>();
8739 let withheld_links = local_view
8740 .withheld_links
8741 .iter()
8742 .filter(|link| touched_sources.contains(link.source.as_str()))
8743 .collect::<Vec<_>>();
8744 let checkout_pseudonym = v2_checkout_id(
8745 baseline
8746 .as_ref()
8747 .and_then(|current| current.checkout_id.as_deref()),
8748 )?;
8749 let checkout_id = if withheld_links.is_empty() {
8750 None
8751 } else {
8752 Some(checkout_pseudonym.clone())
8753 };
8754 if operations.is_empty() {
8755 let final_head = v2_verified_head(cfg, requested_brain)?
8756 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
8757 if !same_v2_head(&head, &final_head) {
8758 return Err(LinkError::RemoteAdvancedDuringSync);
8759 }
8760 let mut final_local = v2_local_files_cached(
8761 store,
8762 Some((&local_view.policy.digest, &local_view.scan_cache)),
8763 )?;
8764 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8765 let final_assets = v2_local_asset_records(store)?;
8766 let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
8767 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
8768 final_local.policy.keeps_home(path)
8769 }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
8770 let next = v2_baseline_from_head(
8771 cfg,
8772 &head,
8773 remote,
8774 remote_assets,
8775 Some(&final_local),
8776 Some(&checkout_pseudonym),
8777 )?;
8778 let split_count = next.remote_copy_remains.len();
8779 accept_v2_head(cfg, &final_head)?;
8780 if !remote_ahead {
8781 refresh_scoped_view_marker(store, &head, next.files.len())?;
8782 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8783 }
8784 return Ok(json!({
8785 "v": 2,
8786 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
8787 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
8788 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8789 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8790 "local_policy": {
8791 "remote_copy_remains": split_count,
8792 },
8793 }));
8794 }
8795 let includes_contract = operations
8796 .iter()
8797 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
8798 let rebase = if head.pointer.is_none() || includes_contract {
8799 "strict"
8800 } else {
8801 "disjoint"
8802 };
8803 let base_value = head.pointer.as_ref().map(|pointer| {
8804 json!({
8805 "seq": pointer.seq,
8806 "commit_hash": pointer.commit_hash,
8807 "content_root": pointer.content_root,
8808 "asset_root": pointer.asset_root,
8809 })
8810 });
8811 let entropy = format!(
8815 "{}\0{}\0{}\0{}\0{}\0{}",
8816 normalized_origin(&cfg.hub)?,
8817 head.brain_id,
8818 serde_json::to_string(&base_value).unwrap_or_default(),
8819 serde_json::to_string(&operations).unwrap_or_default(),
8820 serde_json::to_string(&withheld_links).unwrap_or_default(),
8821 checkout_id.as_deref().unwrap_or("")
8822 );
8823 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
8824 let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
8825 total
8826 .checked_add(source.bytes)
8827 .ok_or_else(|| LinkError::PushTooLarge {
8828 detail: "v2 changed-byte total overflow".to_string(),
8829 })
8830 })?;
8831 let inline = changed_bytes <= 3 * 1024 * 1024;
8832 let inline_blobs = if inline {
8833 upload_sources
8834 .iter()
8835 .map(|(sha256, source)| {
8836 let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
8837 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
8838 return Err(LinkError::InvalidPack {
8839 message: format!("local path `{}` changed before upload", source.path),
8840 });
8841 }
8842 Ok(json!({
8843 "sha256": sha256,
8844 "bytes": source.bytes,
8845 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
8846 }))
8847 })
8848 .collect::<LinkResult<Vec<_>>>()?
8849 } else {
8850 Vec::new()
8851 };
8852 let mut body = json!({
8853 "mutation_id": mutation_id,
8854 "base": base_value,
8855 "rebase": rebase,
8856 "reason": "dbmd sync",
8857 "operations": operations,
8858 "blobs": inline_blobs,
8859 });
8860 if !withheld_links.is_empty() {
8861 body["withheld_links"] = serde_json::to_value(&withheld_links)
8862 .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
8863 body["checkout_id"] =
8864 Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
8865 }
8866 if let Some(confirmation) = bulk_confirmation {
8867 if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
8868 return Err(LinkError::InvalidPack {
8869 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
8870 .to_string(),
8871 });
8872 }
8873 body["rebase"] = Value::String("strict".to_string());
8877 body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8878 body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8879 }
8880 if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8881 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8882 for operation in &operations {
8883 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8884 return Err(invalid_feed("v2 upload operation has no kind"));
8885 };
8886 let hash = match kind {
8887 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
8888 "asset_put" | "asset_resume" => operation
8889 .get("asset")
8890 .and_then(|asset| asset.get("blob_sha256"))
8891 .and_then(Value::as_str),
8892 _ => None,
8893 };
8894 let Some(hash) = hash else { continue };
8895 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8896 if kind == "rename" {
8897 for field in ["from", "to"] {
8898 coordinates.insert(
8899 operation
8900 .get(field)
8901 .and_then(Value::as_str)
8902 .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8903 .to_string(),
8904 );
8905 }
8906 } else {
8907 let path = operation
8908 .get("path")
8909 .and_then(Value::as_str)
8910 .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8911 coordinates.insert(if kind.starts_with("asset_") {
8912 format!("assets/{path}")
8913 } else {
8914 path.to_string()
8915 });
8916 }
8917 }
8918 let declarations = upload_sources
8919 .iter()
8920 .map(|(sha256, source)| {
8921 json!({
8922 "sha256": sha256,
8923 "bytes": source.bytes,
8924 "coordinates": coordinates_by_hash
8925 .get(sha256)
8926 .into_iter()
8927 .flatten()
8928 .collect::<Vec<_>>(),
8929 })
8930 })
8931 .collect::<Vec<_>>();
8932 let mut references = Vec::with_capacity(upload_sources.len());
8933 let mut seen = std::collections::BTreeSet::new();
8934 let mut reserved_count = 0usize;
8935 let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8936 for batch in batch_upload_declarations(declarations) {
8940 let batch_len = batch.len();
8941 let reserved = reserve_upload_window(
8942 cfg,
8943 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8944 &json!({ "blobs": batch }),
8945 "prepare v2 changed-byte uploads",
8946 )?;
8947 let items = reserved
8948 .get("uploads")
8949 .and_then(Value::as_array)
8950 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8951 if items.len() != batch_len {
8952 return Err(invalid_feed(
8953 "v2 upload reservation response changed the requested set",
8954 ));
8955 }
8956 reserved_count += items.len();
8957 for item in items {
8958 let sha256 = item
8959 .get("sha256")
8960 .and_then(Value::as_str)
8961 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8962 let source = upload_sources
8963 .get(sha256)
8964 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8965 let declared_bytes = item
8966 .get("bytes")
8967 .and_then(Value::as_u64)
8968 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8969 let reservation_id = item
8970 .get("reservation_id")
8971 .and_then(Value::as_str)
8972 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8973 let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8974 invalid_feed("v2 upload reservation has no coordinate binding")
8975 })?;
8976 let returned_coordinates = item
8977 .get("coordinates")
8978 .and_then(Value::as_array)
8979 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8980 if declared_bytes != source.bytes
8981 || !crate::ulid::is_ulid(reservation_id)
8982 || !seen.insert(sha256.to_string())
8983 || returned_coordinates.len() != expected_coordinates.len()
8984 || returned_coordinates
8985 .iter()
8986 .zip(expected_coordinates)
8987 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8988 {
8989 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8990 }
8991 match item.get("status").and_then(Value::as_str) {
8992 Some("upload") => {
8993 let url = item
8994 .get("url")
8995 .and_then(Value::as_str)
8996 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8997 pending_uploads.push(V2PendingUpload {
8998 url: url.to_string(),
8999 headers: item.get("headers").cloned().unwrap_or(Value::Null),
9000 sha256: sha256.to_string(),
9001 source,
9002 });
9003 }
9004 Some("already_present") => {}
9005 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
9006 }
9007 references.push(json!({
9008 "sha256": sha256,
9009 "bytes": source.bytes,
9010 "reservation_id": reservation_id,
9011 }));
9012 }
9013 upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
9019 pending_uploads.clear();
9020 }
9021 if reserved_count != upload_sources.len() {
9022 return Err(invalid_feed(
9023 "v2 upload reservation response changed the requested set",
9024 ));
9025 }
9026 body["blobs"] = Value::Array(references);
9027 }
9028 stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
9029 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
9030 let mut candidate_hub_signer: Option<String> = None;
9031 let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
9032 let bulk_preview_required = !(200..300).contains(&response.status)
9033 && response.body.as_ref().is_some_and(|value| {
9034 value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
9035 || value
9036 .get("details")
9037 .and_then(|details| details.get("code"))
9038 .and_then(Value::as_str)
9039 == Some("bulk_preview_required")
9040 });
9041 if bulk_preview_required && bulk_confirmation.is_none() {
9042 body["rebase"] = Value::String("strict".to_string());
9043 body["preview_only"] = Value::Bool(true);
9044 let preview = ensure_ok(
9045 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
9046 "v2 bulk preview",
9047 )?;
9048 let preview_code = preview.get("code").and_then(Value::as_str);
9049 let required = preview.get("required").and_then(Value::as_bool);
9050 if preview.get("v").and_then(Value::as_u64) != Some(2)
9051 || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
9052 || !matches!(
9053 preview_code,
9054 Some("bulk_preview_created" | "bulk_preview_not_required")
9055 )
9056 || required.is_none()
9057 {
9058 return Err(invalid_feed(
9059 "bulk preview response is not bound to the requested mutation",
9060 ));
9061 }
9062 if required == Some(true) {
9063 let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
9064 let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
9065 if preview_code != Some("bulk_preview_created")
9066 || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
9067 || preview_digest.is_none_or(|value| !is_sha256(value))
9068 || preview.get("expires_at").and_then(Value::as_str).is_none()
9069 || !preview.get("impact").is_some_and(Value::is_object)
9070 {
9071 return Err(invalid_feed("bulk preview receipt is malformed"));
9072 }
9073 return Err(LinkError::BulkPreviewRequired { preview });
9074 }
9075 if preview_code != Some("bulk_preview_not_required") {
9076 return Err(invalid_feed("bulk preview requirement is inconsistent"));
9077 }
9078 body.as_object_mut()
9081 .expect("v2 commit request is an object")
9082 .remove("preview_only");
9083 response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
9084 }
9085 let mut result = ensure_ok(response, "v2 sync push")?;
9086 if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
9087 if let Some(object) = result.as_object_mut() {
9088 object.insert(
9089 "sync_status".to_string(),
9090 Value::String("proposal_pending".to_string()),
9091 );
9092 }
9093 return Ok(result);
9094 }
9095 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
9096 let request_id = result
9097 .get("request_id")
9098 .and_then(Value::as_str)
9099 .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
9100 .to_string();
9101 let challenge = result
9102 .get("signing_challenge")
9103 .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
9104 let mut expected_candidate = remote.clone();
9105 let mut expected_candidate_assets = remote_assets.clone();
9106 apply_generated_v2_operations(
9107 &operations,
9108 &local_assets,
9109 &mut expected_candidate,
9110 &mut expected_candidate_assets,
9111 )?;
9112 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
9113 cfg,
9114 &head,
9115 &expected_candidate,
9116 &expected_candidate_assets,
9117 &mutation_id,
9118 &v2_signed_request_view(&body, &operations),
9119 challenge,
9120 )?;
9121 body["signing_challenge_id"] = Value::String(challenge_id);
9122 body["signature_base64url"] = Value::String(signature);
9123 candidate_hub_signer = Some(actor_signer);
9124 result = ensure_ok(
9125 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
9126 "v2 self-custody commit",
9127 )?;
9128 }
9129 let refreshed = v2_verified_head(cfg, requested_brain)?
9130 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
9131 if candidate_hub_signer
9132 .as_ref()
9133 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
9134 {
9135 return Err(invalid_feed(
9136 "self-custody actor signer differs from the committed hub pointer signer",
9137 ));
9138 }
9139 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
9140 if refreshed
9141 .pointer
9142 .as_ref()
9143 .map(|pointer| pointer.commit_hash.as_str())
9144 != accepted_hash
9145 {
9146 return Err(LinkError::RemoteAdvancedDuringSync);
9147 }
9148 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
9149 let rebased = result
9150 .get("rebased")
9151 .and_then(Value::as_bool)
9152 .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
9153 let (refreshed_files, refreshed_assets) = if rebased {
9154 (
9155 files_for_v2_view(
9156 &refreshed,
9157 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
9158 ),
9159 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
9160 )
9161 } else {
9162 let asset_changed = apply_generated_v2_operations(
9163 &operations,
9164 &local_assets,
9165 &mut remote,
9166 &mut remote_assets,
9167 )?;
9168 let assets = if asset_changed {
9169 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
9172 } else {
9173 remote_assets
9174 };
9175 (remote, assets)
9176 };
9177 let mut final_local = v2_local_files_cached(
9178 store,
9179 Some((&local_view.policy.digest, &local_view.scan_cache)),
9180 )?;
9181 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
9182 let final_assets = v2_local_asset_records(store)?;
9183 let local_dirty = final_local.riding != local_view.riding
9184 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
9185 final_local.policy.keeps_home(path)
9186 })
9187 || final_assets != local_assets
9188 || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
9189 let contract_handoff = contract_phase.then(|| V2PulledSnapshot {
9195 report: PullReport {
9196 brain: refreshed.brain_id.clone(),
9197 slug: requested_brain.to_string(),
9198 head_seq: refreshed.pointer.as_ref().map_or(0, |pointer| pointer.seq),
9199 files: refreshed_files.len(),
9200 dest: store.root.to_string_lossy().into_owned(),
9201 extra_local: Vec::new(),
9202 sync_status: "contract_phase".to_string(),
9203 },
9204 head: refreshed.clone(),
9205 files: refreshed_files.clone(),
9206 assets: refreshed_assets.clone(),
9207 local: final_local.clone(),
9208 local_assets: final_assets.clone(),
9209 });
9210 let next = v2_baseline_from_head(
9211 cfg,
9212 &refreshed,
9213 refreshed_files,
9214 refreshed_assets,
9215 Some(&final_local),
9216 Some(&checkout_pseudonym),
9217 )?;
9218 let split_count = next.remote_copy_remains.len();
9219 accept_v2_head(cfg, &refreshed)?;
9220 if !local_dirty {
9221 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
9222 }
9223 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
9228 if let Some(object) = result.as_object_mut() {
9229 object.insert(
9230 "local_policy".to_string(),
9231 json!({ "remote_copy_remains": split_count }),
9232 );
9233 object.insert(
9234 "sync_status".to_string(),
9235 Value::String(if local_dirty {
9236 "remote_committed_local_dirty".to_string()
9237 } else {
9238 "synced".to_string()
9239 }),
9240 );
9241 }
9242 if contract_phase && result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9243 let contract_receipt = result;
9244 drop(_operation_lock);
9249 let mut remaining = v2_sync_push(
9250 cfg,
9251 requested_brain,
9252 store,
9253 refreshed,
9254 V2SyncPushOptions {
9255 resume_local_policy,
9256 bulk_confirmation,
9257 resolution,
9258 pulled: contract_handoff,
9259 withdrawal_paths,
9260 withdrawal_reason,
9261 allow_contract_phase: false,
9262 },
9263 )?;
9264 if let Some(object) = remaining.as_object_mut() {
9265 object.insert("contract_phase".to_string(), contract_receipt);
9266 }
9267 return Ok(remaining);
9268 }
9269 if contract_phase {
9270 if let Some(object) = result.as_object_mut() {
9271 object.insert("contract_phase_pending".to_string(), Value::Bool(true));
9272 }
9273 }
9274 Ok(result)
9275}
9276
9277pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
9280 sync_push_incremental_with_policy(cfg, brain, store, false)
9281}
9282
9283pub fn sync_push_incremental_with_policy(
9286 cfg: &HubConfig,
9287 brain: &str,
9288 store: &Store,
9289 resume_local_policy: bool,
9290) -> LinkResult<Value> {
9291 sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
9292}
9293
9294pub fn sync_push_incremental_with_options(
9297 cfg: &HubConfig,
9298 brain: &str,
9299 store: &Store,
9300 resume_local_policy: bool,
9301 bulk_confirmation: Option<&V2BulkConfirmation>,
9302) -> LinkResult<Value> {
9303 sync_push_incremental_with_controls(
9304 cfg,
9305 brain,
9306 store,
9307 resume_local_policy,
9308 bulk_confirmation,
9309 &[],
9310 None,
9311 )
9312}
9313
9314pub fn sync_push_incremental_with_controls(
9316 cfg: &HubConfig,
9317 brain: &str,
9318 store: &Store,
9319 resume_local_policy: bool,
9320 bulk_confirmation: Option<&V2BulkConfirmation>,
9321 withdrawal_paths: &[String],
9322 withdrawal_reason: Option<&str>,
9323) -> LinkResult<Value> {
9324 require_safe_ref(brain)?;
9325 if let Some(head) = v2_verified_head(cfg, brain)? {
9326 return v2_sync_push(
9327 cfg,
9328 brain,
9329 store,
9330 head,
9331 V2SyncPushOptions {
9332 resume_local_policy,
9333 bulk_confirmation,
9334 resolution: None,
9335 pulled: None,
9336 withdrawal_paths,
9337 withdrawal_reason,
9338 allow_contract_phase: true,
9339 },
9340 );
9341 }
9342 if !withdrawal_paths.is_empty() {
9343 return Err(LinkError::InvalidPack {
9344 message: "explicit withdrawal requires a link.md v2 brain".to_string(),
9345 });
9346 }
9347 legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
9348}
9349
9350pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
9354 require_safe_ref(brain)?;
9355 Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
9356}
9357
9358pub fn relocate_v2_sync_baseline(
9364 cfg: &HubConfig,
9365 brain: &str,
9366 from: &Path,
9367 to: &Path,
9368) -> LinkResult<Value> {
9369 require_hardened_filesystem("verified link.md v2 baseline relocation")?;
9370 require_safe_ref(brain)?;
9371 if !crate::ulid::is_ulid(brain) {
9372 return Err(invalid_feed(
9373 "v2 baseline relocation requires the canonical brain id",
9374 ));
9375 }
9376 let from_absolute = if from.is_absolute() {
9377 from.to_path_buf()
9378 } else {
9379 std::env::current_dir()?.join(from)
9380 };
9381 let to_absolute = if to.is_absolute() {
9382 to.to_path_buf()
9383 } else {
9384 std::env::current_dir()?.join(to)
9385 };
9386 let source_name = v2_baseline_name(cfg, brain, &from_absolute)?;
9387 let target_name = v2_baseline_name(cfg, brain, &to_absolute)?;
9388 if source_name == target_name {
9389 return Err(invalid_feed(
9390 "v2 baseline relocation source and destination are the same checkout",
9391 ));
9392 }
9393 match std::fs::symlink_metadata(&from_absolute) {
9394 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
9395 Err(error) => return Err(error.into()),
9396 Ok(_) => {
9397 return Err(LinkError::InvalidPack {
9398 message: "the old checkout still exists; move it before relocating its baseline"
9399 .to_string(),
9400 })
9401 }
9402 }
9403 let store = Store::open_strict(&to_absolute).map_err(|error| LinkError::InvalidPack {
9404 message: format!("relocated checkout is not a valid db.md store: {error}"),
9405 })?;
9406 let _operation_lock = lock_v2_sync_operation(cfg, brain)?;
9407
9408 #[cfg(any(unix, windows))]
9409 {
9410 let directory = open_trust_dir(cfg)?;
9411 let mut lock_names = [source_name.as_str(), target_name.as_str()];
9412 lock_names.sort();
9413 let _locks = lock_names
9414 .iter()
9415 .map(|name| lock_trust_name(&directory, name))
9416 .collect::<LinkResult<Vec<_>>>()?;
9417 let source = load_v2_baseline_in(cfg, brain, &directory, &source_name)?;
9418 let target = load_v2_baseline_in(cfg, brain, &directory, &target_name)?;
9419 let (baseline, already_relocated) = match (source, target) {
9420 (Some(source), None) => (source, false),
9421 (None, Some(target)) => (target, true),
9422 (Some(_), Some(_)) => {
9423 return Err(LinkError::InvalidPack {
9424 message: "both old and new checkout paths already have private sync baselines"
9425 .to_string(),
9426 })
9427 }
9428 (None, None) => {
9429 return Err(LinkError::InvalidPack {
9430 message: "the old checkout has no verified incremental baseline to relocate"
9431 .to_string(),
9432 })
9433 }
9434 };
9435
9436 let mut local = v2_local_files(&store)?;
9437 if baseline.view_kind.as_deref() == Some("scoped") {
9438 let expected = baseline
9439 .projection_sha256
9440 .as_deref()
9441 .ok_or_else(|| invalid_feed("scoped baseline has no projection hash"))?;
9442 if local.riding.get("DB.md").map(|value| value.0.as_str()) != Some(expected) {
9443 return Err(LinkError::ScopedProjectionModified);
9444 }
9445 local.riding.remove("DB.md");
9446 }
9447 if baseline.local_policy_digest.as_deref() != Some(local.policy.digest.as_str())
9448 || !v2_riding_matches_remote(&local.riding, &baseline.files, |path| {
9449 local.policy.keeps_home(path)
9450 })
9451 || !v2_asset_records_match_remote(&v2_local_asset_records(&store)?, &baseline.assets)
9452 {
9453 return Err(LinkError::InvalidPack {
9454 message: "the moved checkout no longer matches its verified incremental baseline"
9455 .to_string(),
9456 });
9457 }
9458 if !already_relocated {
9459 crate::fsx::rename_beneath(
9460 &directory,
9461 Path::new(&source_name),
9462 Path::new(&target_name),
9463 )?;
9464 directory.sync_all()?;
9465 }
9466 Ok(json!({
9467 "v": 2,
9468 "class": "checkout_baseline_relocated",
9469 "brain": baseline.brain,
9470 "from": from_absolute,
9471 "to": to_absolute,
9472 "headSeq": baseline.head_seq.unwrap_or(0),
9473 "commitHash": baseline.commit_hash,
9474 "moved": !already_relocated,
9475 }))
9476 }
9477
9478 #[cfg(not(any(unix, windows)))]
9479 Err(LinkError::UnsupportedPlatform {
9480 operation: "verified link.md v2 baseline relocation",
9481 })
9482}
9483
9484#[cfg(windows)]
9485fn legacy_sync_push_incremental(
9486 _cfg: &HubConfig,
9487 _brain: &str,
9488 _store: &Store,
9489 _resume_local_policy: bool,
9490 _bulk_confirmation: Option<&V2BulkConfirmation>,
9491) -> LinkResult<Value> {
9492 Err(LinkError::UnsupportedPlatform {
9493 operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
9494 })
9495}
9496
9497#[cfg(not(windows))]
9498fn legacy_sync_push_incremental(
9499 cfg: &HubConfig,
9500 brain: &str,
9501 store: &Store,
9502 resume_local_policy: bool,
9503 bulk_confirmation: Option<&V2BulkConfirmation>,
9504) -> LinkResult<Value> {
9505 if resume_local_policy || bulk_confirmation.is_some() {
9506 return Err(LinkError::InvalidPack {
9507 message: "v2 sync options require a link.md v2 brain".to_string(),
9508 });
9509 }
9510 let files = collect_push_files(store)?;
9511 sync_push(cfg, brain, &files)
9512}
9513
9514#[derive(Debug, Clone)]
9516pub enum V2ConflictChoice {
9517 KeepLocal,
9518 TakeRemote,
9519 From(PathBuf),
9520}
9521
9522fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
9523 if !crate::ulid::is_ulid(bundle) {
9524 return Err(LinkError::InvalidPack {
9525 message: "conflict bundle must be a lowercase ULID".to_string(),
9526 });
9527 }
9528 let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
9529 let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
9530 .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
9531 if plan.v != 2
9532 || plan.class != "content_resolution_required"
9533 || plan.bundle != bundle
9534 || !crate::ulid::is_ulid(&plan.brain)
9535 || plan.files.is_empty()
9536 || plan.files.len() > 100
9537 || plan.files.iter().any(|file| {
9538 crate::linkmd_v2::normalize_path(&file.path).is_err()
9539 || [&file.base, &file.local, &file.remote]
9540 .into_iter()
9541 .any(|coordinate| {
9542 coordinate
9543 .sha256
9544 .as_deref()
9545 .is_some_and(|hash| !is_sha256(hash))
9546 || coordinate.file.as_deref().is_some_and(|name| {
9547 name.starts_with('/')
9548 || name
9549 .split('/')
9550 .any(|part| part.is_empty() || part == "." || part == "..")
9551 })
9552 })
9553 })
9554 {
9555 return Err(invalid_feed("private conflict plan failed validation"));
9556 }
9557 Ok(plan)
9558}
9559
9560pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
9565 require_hardened_filesystem("private conflict maintenance")?;
9566 if all && !prune {
9567 return Err(LinkError::InvalidPack {
9568 message: "discarding all conflict bundles requires prune=true".to_string(),
9569 });
9570 }
9571 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9572 message: format!("conflict checkout is not a valid db.md store: {error}"),
9573 })?;
9574 let _transaction = store.transaction()?;
9575 let root = Path::new(".dbmd/conflicts");
9576 let names = match store.directory_names(root) {
9577 Ok(names) => names,
9578 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
9579 Err(error) => return Err(error.into()),
9580 };
9581 let now = SystemTime::now()
9582 .duration_since(UNIX_EPOCH)
9583 .unwrap_or_default()
9584 .as_secs();
9585 let mut bundles = Vec::new();
9586 let mut pruned = 0_u64;
9587 for name in names {
9588 let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
9589 continue;
9590 };
9591 let plan_path = v2_conflict_relative(bundle, "plan.json");
9592 let plan_exists = store.regular_file_exists(&plan_path)?;
9593 let expired = if plan_exists {
9594 match load_v2_conflict_plan(&store, bundle) {
9595 Ok(plan) => plan.expires_unix < now,
9596 Err(error) if all => {
9597 let _ = error;
9598 true
9599 }
9600 Err(error) => return Err(error),
9601 }
9602 } else {
9603 true
9604 };
9605 if prune && (all || expired) {
9606 store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9607 pruned += 1;
9608 continue;
9609 }
9610 bundles.push(json!({
9611 "bundle": bundle,
9612 "complete": plan_exists,
9613 "expired": expired,
9614 }));
9615 }
9616 Ok(json!({
9617 "v": 2,
9618 "class": "private_conflict_state",
9619 "bundles": bundles.len(),
9620 "pruned": pruned,
9621 "items": bundles,
9622 }))
9623}
9624
9625pub fn sync_resolve_conflict(
9629 cfg: &HubConfig,
9630 checkout: &Path,
9631 bundle: &str,
9632 choice: V2ConflictChoice,
9633 bulk_confirmation: Option<&V2BulkConfirmation>,
9634) -> LinkResult<Value> {
9635 require_hardened_filesystem("conflict resolution")?;
9636 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9637 message: format!("conflict checkout is not a valid db.md store: {error}"),
9638 })?;
9639 let plan = load_v2_conflict_plan(&store, bundle)?;
9640 if plan.origin != normalized_origin(&cfg.hub)? {
9641 return Err(invalid_feed(
9642 "conflict bundle belongs to another hub origin",
9643 ));
9644 }
9645 let now = SystemTime::now()
9646 .duration_since(UNIX_EPOCH)
9647 .unwrap_or_default()
9648 .as_secs();
9649 if now > plan.expires_unix {
9650 return Err(LinkError::InvalidPack {
9651 message: "conflict bundle expired; rerun sync to obtain current coordinates"
9652 .to_string(),
9653 });
9654 }
9655 let head = v2_verified_head(cfg, &plan.brain)?
9656 .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
9657 let pointer = head.pointer.as_ref();
9658 if pointer.map_or(0, |value| value.seq) != plan.remote_seq
9659 || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
9660 || pointer.and_then(|value| value.content_root.as_deref())
9661 != plan.remote_content_root.as_deref()
9662 || head.view_kind != plan.view_kind
9663 || head.view_revision != plan.view_revision
9664 {
9665 return Err(LinkError::RemoteAdvancedDuringSync);
9666 }
9667
9668 for file in &plan.files {
9670 let actual = match store.regular_file_exists(Path::new(&file.path))? {
9671 true => Some(content_sha256(&store.read_bounded(
9672 Path::new(&file.path),
9673 file.local.bytes.unwrap_or(MAX_STORE_BYTES),
9674 )?)),
9675 false => None,
9676 };
9677 if actual.as_deref() != file.local.sha256.as_deref() {
9678 return Err(LinkError::InvalidPack {
9679 message: format!(
9680 "local conflict path `{}` changed after the bundle was created",
9681 file.path
9682 ),
9683 });
9684 }
9685 }
9686
9687 let from_source = match &choice {
9688 V2ConflictChoice::From(source) => Some(source.clone()),
9689 _ => None,
9690 };
9691 let result = match choice {
9692 V2ConflictChoice::TakeRemote => {
9693 if bulk_confirmation.is_some() {
9694 return Err(LinkError::InvalidPack {
9695 message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
9696 });
9697 }
9698 let current_remote =
9702 files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
9703 let _ = v2_take_remote_selection(&plan.files, ¤t_remote)?;
9704 let selected = plan
9705 .files
9706 .iter()
9707 .map(|file| file.path.clone())
9708 .collect::<std::collections::BTreeSet<_>>();
9709 serde_json::to_value(
9710 v2_sync_pull_with_resolution(
9711 cfg,
9712 &plan.brain,
9713 head,
9714 Some(checkout),
9715 Some(&selected),
9716 )?
9717 .report,
9718 )
9719 .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
9720 }
9721 V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
9722 if let Some(source) = from_source.as_ref() {
9723 if plan.files.len() != 1 {
9724 return Err(LinkError::InvalidPack {
9725 message: "--from requires a bundle with exactly one conflict".to_string(),
9726 });
9727 }
9728 let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
9729 if std::str::from_utf8(&candidate).is_err() {
9730 return Err(LinkError::NotUtf8 {
9731 path: source.display().to_string(),
9732 });
9733 }
9734 store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
9735 }
9736 let refreshed_store =
9737 Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9738 message: format!("resolved checkout is not a valid db.md store: {error}"),
9739 })?;
9740 let mut overrides = std::collections::BTreeMap::new();
9741 for file in &plan.files {
9742 let selected_local = match refreshed_store
9743 .regular_file_exists(Path::new(&file.path))?
9744 {
9745 true => Some(content_sha256(
9746 &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
9747 )),
9748 false => None,
9749 };
9750 overrides.insert(
9751 file.path.clone(),
9752 V2ResolutionOverride {
9753 expected_remote: file.remote.sha256.clone(),
9754 selected_local,
9755 },
9756 );
9757 }
9758 v2_sync_push(
9759 cfg,
9760 &plan.brain,
9761 &refreshed_store,
9762 head,
9763 V2SyncPushOptions {
9764 resume_local_policy: true,
9765 bulk_confirmation,
9766 resolution: Some(&overrides),
9767 pulled: None,
9768 withdrawal_paths: &[],
9769 withdrawal_reason: None,
9770 allow_contract_phase: true,
9771 },
9772 )?
9773 }
9774 };
9775
9776 if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9777 let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9778 message: format!("resolved checkout is not a valid db.md store: {error}"),
9779 })?;
9780 installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9781 }
9782 Ok(json!({
9783 "v": 2,
9784 "class": "auto_converged",
9785 "bundle": bundle,
9786 "receipt": result,
9787 }))
9788}
9789
9790pub fn sync_converge(
9801 cfg: &HubConfig,
9802 brain: &str,
9803 checkout: &Path,
9804 resume_local_policy: bool,
9805) -> LinkResult<Value> {
9806 sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
9807}
9808
9809pub fn sync_converge_with_options(
9811 cfg: &HubConfig,
9812 brain: &str,
9813 checkout: &Path,
9814 resume_local_policy: bool,
9815 bulk_confirmation: Option<&V2BulkConfirmation>,
9816) -> LinkResult<Value> {
9817 sync_converge_with_controls(
9818 cfg,
9819 brain,
9820 checkout,
9821 resume_local_policy,
9822 bulk_confirmation,
9823 &[],
9824 None,
9825 )
9826}
9827
9828pub fn sync_converge_with_controls(
9830 cfg: &HubConfig,
9831 brain: &str,
9832 checkout: &Path,
9833 resume_local_policy: bool,
9834 bulk_confirmation: Option<&V2BulkConfirmation>,
9835 withdrawal_paths: &[String],
9836 withdrawal_reason: Option<&str>,
9837) -> LinkResult<Value> {
9838 require_hardened_filesystem("bidirectional sync")?;
9839 require_safe_ref(brain)?;
9840 let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
9841 message:
9842 "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
9843 .to_string(),
9844 })?;
9845 let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
9846 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9847 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9848 })?;
9849 let _transaction = store.transaction()?;
9850 let pulled_report = pulled.report.clone();
9851 let pulled_head = pulled.head.clone();
9852 let mut result = v2_sync_push(
9853 cfg,
9854 brain,
9855 &store,
9856 pulled_head,
9857 V2SyncPushOptions {
9858 resume_local_policy,
9859 bulk_confirmation,
9860 resolution: None,
9861 pulled: Some(pulled),
9862 withdrawal_paths,
9863 withdrawal_reason,
9864 allow_contract_phase: true,
9865 },
9866 )?;
9867 if let Some(object) = result.as_object_mut() {
9868 object.insert("pulled_files".to_string(), json!(pulled_report.files));
9869 object.insert("checkout".to_string(), Value::String(pulled_report.dest));
9870 object.insert(
9871 "mode".to_string(),
9872 Value::String("bidirectional".to_string()),
9873 );
9874 }
9875 Ok(result)
9876}
9877
9878pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9884 require_hardened_filesystem("sync pull")?;
9885 require_safe_ref(brain)?;
9886 if let Some(head) = v2_verified_head(cfg, brain)? {
9887 return v2_sync_pull(cfg, brain, head, out);
9888 }
9889 legacy_sync_pull(cfg, brain, out)
9890}
9891
9892#[cfg(windows)]
9893fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
9894 Err(LinkError::UnsupportedPlatform {
9895 operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
9896 })
9897}
9898
9899#[cfg(not(windows))]
9900fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9901 let remote = verified_remote_head(cfg, brain, false)?;
9902 if !remote.head.verified {
9903 return Err(invalid_feed(
9904 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
9905 ));
9906 }
9907 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
9908 let path = format!(
9909 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
9910 remote.head.seq
9911 );
9912 let body = ensure_ok(
9913 request(cfg, "GET", &path, None, Auth::Required)?,
9914 "sync pull",
9915 )?;
9916 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
9917 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
9918 {
9919 return Err(invalid_feed(
9920 "export response is not bound to the verified snapshot token",
9921 ));
9922 }
9923
9924 let remote_slug = body
9925 .get("slug")
9926 .and_then(Value::as_str)
9927 .filter(|slug| is_safe_slug(slug));
9928 let slug = remote_slug
9929 .or_else(|| is_safe_slug(brain).then_some(brain))
9930 .unwrap_or("brain")
9931 .to_string();
9932 let brain_id = body
9933 .get("brain")
9934 .and_then(Value::as_str)
9935 .unwrap_or(&remote.head.brain)
9936 .to_string();
9937 if brain_id != remote.head.brain {
9938 return Err(invalid_feed(
9939 "export response names a different brain than the verified head",
9940 ));
9941 }
9942 let head_seq = remote.head.seq;
9943 let dest: PathBuf = match out {
9944 Some(p) => p.to_path_buf(),
9945 None => PathBuf::from(&slug),
9946 };
9947 let entries = if head_seq == 0 {
9948 let files = body
9949 .get("files")
9950 .and_then(Value::as_array)
9951 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
9952 if !files.is_empty() || body.get("url").is_some() {
9953 return Err(invalid_feed(
9954 "empty signed feed cannot authorize non-empty exported content",
9955 ));
9956 }
9957 Vec::new()
9958 } else {
9959 let signed_head = remote
9960 .head_entry
9961 .as_ref()
9962 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
9963 let expected = &signed_head.entry.pack_sha256;
9964 if !is_sha256(expected) {
9965 return Err(invalid_feed(
9966 "signed head carries an invalid snapshot pack digest",
9967 ));
9968 }
9969 if let Some(url) = body.get("url").and_then(Value::as_str) {
9970 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
9971 return Err(invalid_feed(
9972 "export pack digest does not match the signed head entry",
9973 ));
9974 }
9975 let bytes = get_presigned(cfg, url)?;
9976 let actual = format!("{:x}", Sha256::digest(&bytes));
9977 if actual != *expected {
9978 return Err(LinkError::InvalidPack {
9979 message: "downloaded pack does not match the signed snapshot digest"
9980 .to_string(),
9981 });
9982 }
9983 let entries = parse_store_pack(bytes)?;
9984 if signed_head.entry.kind == "push" {
9985 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9986 }
9987 entries
9988 } else {
9989 if signed_head.entry.kind != "push" {
9990 return Err(invalid_feed(
9991 "delta snapshots must export the exact signed pack",
9992 ));
9993 }
9994 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
9995 invalid_feed("verified snapshot export carried neither a pack nor files")
9996 })?;
9997 let mut entries = Vec::with_capacity(files.len());
9998 for file in files {
9999 let path = file
10000 .get("path")
10001 .and_then(Value::as_str)
10002 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
10003 let content = file
10004 .get("content")
10005 .and_then(Value::as_str)
10006 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
10007 entries.push((path.to_string(), content.as_bytes().to_vec()));
10008 }
10009 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
10010 entries
10011 }
10012 };
10013
10014 let mut seen = std::collections::HashSet::new();
10016 for (path, _) in &entries {
10017 if !safe_store_rel_path(path) {
10018 return Err(LinkError::UnsafePath { path: path.clone() });
10019 }
10020 if !seen.insert(path) {
10021 return Err(LinkError::InvalidPack {
10022 message: format!("duplicate path `{path}`"),
10023 });
10024 }
10025 }
10026 let pulled: std::collections::BTreeSet<&str> =
10029 entries.iter().map(|(p, _)| p.as_str()).collect();
10030 let mut extra_local = Vec::new();
10031 if let Ok(store) = Store::open(&dest) {
10032 if let Ok(walked) = store.walk() {
10033 for rel in walked {
10034 let rel_str = rel.to_string_lossy().replace('\\', "/");
10035 if !pulled.contains(rel_str.as_str()) {
10036 extra_local.push(rel_str);
10037 }
10038 }
10039 }
10040 }
10041 #[cfg(unix)]
10042 install_pulled_snapshot(&dest, &entries)?;
10043
10044 Ok(PullReport {
10045 brain: brain_id,
10046 slug,
10047 head_seq,
10048 files: entries.len(),
10049 dest: dest.to_string_lossy().into_owned(),
10050 extra_local,
10051 sync_status: "synced".to_string(),
10052 })
10053}
10054
10055#[cfg(unix)]
10056fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
10057 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
10058 path: display.to_string(),
10059 })
10060}
10061
10062#[cfg(unix)]
10063fn open_dir_at(
10064 parent: std::os::fd::RawFd,
10065 name: &std::ffi::CStr,
10066 display: &str,
10067) -> LinkResult<std::fs::File> {
10068 use std::os::fd::FromRawFd as _;
10069 let fd = unsafe {
10070 libc::openat(
10071 parent,
10072 name.as_ptr(),
10073 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10074 )
10075 };
10076 if fd < 0 {
10077 return Err(LinkError::UnsafePath {
10078 path: display.to_string(),
10079 });
10080 }
10081 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
10082}
10083
10084#[cfg(unix)]
10088fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
10089 use std::os::fd::AsRawFd as _;
10090
10091 #[cfg(target_os = "macos")]
10095 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
10096 .into_iter()
10097 .find_map(|(alias, real)| {
10098 path.strip_prefix(alias)
10099 .ok()
10100 .map(|rest| Path::new(real).join(rest))
10101 })
10102 .unwrap_or_else(|| path.to_path_buf());
10103 #[cfg(not(target_os = "macos"))]
10104 let normalized = path.to_path_buf();
10105
10106 let start = if normalized.is_absolute() {
10107 std::fs::File::open("/")?
10108 } else {
10109 std::fs::File::open(".")?
10110 };
10111 let mut directory = start;
10112 for component in normalized.components() {
10113 use std::path::Component;
10114 let name = match component {
10115 Component::RootDir | Component::CurDir => continue,
10116 Component::Normal(name) => name,
10117 Component::ParentDir | Component::Prefix(_) => {
10118 return Err(LinkError::UnsafePath {
10119 path: path.display().to_string(),
10120 });
10121 }
10122 };
10123 use std::os::unix::ffi::OsStrExt as _;
10124 let name = c_name(name.as_bytes(), &path.display().to_string())?;
10125 if create {
10126 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10127 if made != 0 {
10128 let error = std::io::Error::last_os_error();
10129 if error.raw_os_error() != Some(libc::EEXIST) {
10130 return Err(error.into());
10131 }
10132 }
10133 }
10134 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
10135 }
10136 Ok(directory)
10137}
10138
10139#[cfg(unix)]
10140fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
10141 open_dir_path_nofollow(path, true)
10142}
10143
10144#[cfg(unix)]
10145fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
10146 open_dir_path_nofollow(path, false)
10147}
10148
10149#[cfg(unix)]
10150fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
10151 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
10152 let result =
10153 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
10154 if result == 0 {
10155 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
10156 }
10157 let error = std::io::Error::last_os_error();
10158 if error.kind() == std::io::ErrorKind::NotFound {
10159 Ok(None)
10160 } else {
10161 Err(error.into())
10162 }
10163}
10164
10165#[cfg(unix)]
10166fn create_dir_exclusive_at(
10167 parent: std::os::fd::RawFd,
10168 name: &std::ffi::CStr,
10169 display: &str,
10170) -> LinkResult<std::fs::File> {
10171 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
10172 if made != 0 {
10173 return Err(LinkError::UnsafePath {
10174 path: display.to_string(),
10175 });
10176 }
10177 open_dir_at(parent, name, display)
10178}
10179
10180#[cfg(unix)]
10181fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
10182 use std::os::fd::AsRawFd as _;
10183
10184 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
10185 if duplicate < 0 {
10186 return Err(std::io::Error::last_os_error().into());
10187 }
10188 let stream = unsafe { libc::fdopendir(duplicate) };
10189 if stream.is_null() {
10190 let error = std::io::Error::last_os_error();
10191 unsafe {
10192 libc::close(duplicate);
10193 }
10194 return Err(error.into());
10195 }
10196 let mut names = Vec::new();
10197 loop {
10198 let entry = unsafe { libc::readdir(stream) };
10199 if entry.is_null() {
10200 break;
10201 }
10202 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
10203 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
10204 names.push(raw.to_owned());
10205 }
10206 }
10207 if unsafe { libc::closedir(stream) } != 0 {
10208 return Err(std::io::Error::last_os_error().into());
10209 }
10210 Ok(names)
10211}
10212
10213#[cfg(unix)]
10216fn remove_tree_at(
10217 parent: std::os::fd::RawFd,
10218 name: &std::ffi::CStr,
10219 display: &str,
10220) -> LinkResult<()> {
10221 use std::os::fd::AsRawFd as _;
10222
10223 match entry_is_dir_at(parent, name)? {
10224 None => return Ok(()),
10225 Some(false) => {
10226 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
10227 return Err(std::io::Error::last_os_error().into());
10228 }
10229 }
10230 Some(true) => {
10231 let directory = open_dir_at(parent, name, display)?;
10232 for child in directory_entry_names(&directory)? {
10233 let child_display =
10234 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
10235 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
10236 }
10237 drop(directory);
10238 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
10239 return Err(std::io::Error::last_os_error().into());
10240 }
10241 }
10242 }
10243 Ok(())
10244}
10245
10246#[cfg(unix)]
10250fn clone_tree_contents(
10251 source: &std::fs::File,
10252 destination: &std::fs::File,
10253 display: &str,
10254) -> LinkResult<()> {
10255 use std::os::fd::{AsRawFd as _, FromRawFd as _};
10256
10257 for name in directory_entry_names(source)? {
10258 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
10259 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
10260 if unsafe {
10261 libc::fstatat(
10262 source.as_raw_fd(),
10263 name.as_ptr(),
10264 &mut stat,
10265 libc::AT_SYMLINK_NOFOLLOW,
10266 )
10267 } != 0
10268 {
10269 return Err(std::io::Error::last_os_error().into());
10270 }
10271 match stat.st_mode & libc::S_IFMT {
10272 libc::S_IFDIR => {
10273 if unsafe {
10274 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
10275 } != 0
10276 {
10277 return Err(std::io::Error::last_os_error().into());
10278 }
10279 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
10280 let destination_child =
10281 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
10282 clone_tree_contents(&source_child, &destination_child, &child_display)?;
10283 destination_child.sync_all()?;
10284 }
10285 libc::S_IFREG => {
10286 let source_fd = unsafe {
10287 libc::openat(
10288 source.as_raw_fd(),
10289 name.as_ptr(),
10290 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10291 )
10292 };
10293 if source_fd < 0 {
10294 return Err(std::io::Error::last_os_error().into());
10295 }
10296 let destination_fd = unsafe {
10297 libc::openat(
10298 destination.as_raw_fd(),
10299 name.as_ptr(),
10300 libc::O_WRONLY
10301 | libc::O_CREAT
10302 | libc::O_EXCL
10303 | libc::O_CLOEXEC
10304 | libc::O_NOFOLLOW,
10305 (stat.st_mode & 0o777) as libc::c_uint,
10306 )
10307 };
10308 if destination_fd < 0 {
10309 unsafe {
10310 libc::close(source_fd);
10311 }
10312 return Err(std::io::Error::last_os_error().into());
10313 }
10314 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
10315 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
10316 std::io::copy(&mut input, &mut output)?;
10317 output.sync_all()?;
10318 }
10319 libc::S_IFLNK => {
10320 let mut target = vec![0_u8; 4097];
10321 let length = unsafe {
10322 libc::readlinkat(
10323 source.as_raw_fd(),
10324 name.as_ptr(),
10325 target.as_mut_ptr().cast(),
10326 target.len(),
10327 )
10328 };
10329 if length < 0 || length as usize >= target.len() {
10330 return Err(LinkError::UnsafePath {
10331 path: child_display,
10332 });
10333 }
10334 target.truncate(length as usize);
10335 let target = c_name(&target, &child_display)?;
10336 if unsafe {
10337 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
10338 } != 0
10339 {
10340 return Err(std::io::Error::last_os_error().into());
10341 }
10342 }
10343 _ => {
10344 return Err(LinkError::UnsafePath {
10345 path: child_display,
10346 });
10347 }
10348 }
10349 }
10350 destination.sync_all()?;
10351 Ok(())
10352}
10353
10354#[cfg(target_os = "linux")]
10355fn install_stage_at(
10356 parent: std::os::fd::RawFd,
10357 stage: &std::ffi::CStr,
10358 dest: &std::ffi::CStr,
10359 dest_exists: bool,
10360) -> LinkResult<()> {
10361 let flags = if dest_exists {
10362 libc::RENAME_EXCHANGE
10363 } else {
10364 libc::RENAME_NOREPLACE
10365 };
10366 let result = unsafe {
10370 libc::syscall(
10371 libc::SYS_renameat2,
10372 parent,
10373 stage.as_ptr(),
10374 parent,
10375 dest.as_ptr(),
10376 flags,
10377 )
10378 };
10379 if result == 0 {
10380 Ok(())
10381 } else {
10382 Err(std::io::Error::last_os_error().into())
10383 }
10384}
10385
10386#[cfg(target_os = "macos")]
10387fn install_stage_at(
10388 parent: std::os::fd::RawFd,
10389 stage: &std::ffi::CStr,
10390 dest: &std::ffi::CStr,
10391 dest_exists: bool,
10392) -> LinkResult<()> {
10393 let flags = if dest_exists {
10394 libc::RENAME_SWAP
10395 } else {
10396 libc::RENAME_EXCL
10397 };
10398 let result =
10399 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
10400 if result == 0 {
10401 Ok(())
10402 } else {
10403 Err(std::io::Error::last_os_error().into())
10404 }
10405}
10406
10407#[cfg(unix)]
10408fn write_pull_entries_beneath_dir(
10409 root: &std::fs::File,
10410 entries: &[(String, Vec<u8>)],
10411) -> LinkResult<()> {
10412 use std::os::fd::{AsRawFd as _, FromRawFd as _};
10413
10414 for (path, content) in entries {
10415 let components: Vec<&str> = path.split('/').collect();
10416 let (leaf, parents) = components
10417 .split_last()
10418 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10419 let mut directory = root.try_clone()?;
10420 for component in parents {
10421 let name = c_name(component.as_bytes(), path)?;
10422 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10423 if made != 0 {
10424 let error = std::io::Error::last_os_error();
10425 if error.raw_os_error() != Some(libc::EEXIST) {
10426 return Err(error.into());
10427 }
10428 }
10429 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10430 }
10431
10432 let leaf_name = c_name(leaf.as_bytes(), path)?;
10433 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
10434 let inspected = unsafe {
10435 libc::fstatat(
10436 directory.as_raw_fd(),
10437 leaf_name.as_ptr(),
10438 &mut existing,
10439 libc::AT_SYMLINK_NOFOLLOW,
10440 )
10441 };
10442 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
10443 return Err(LinkError::UnsafePath { path: path.clone() });
10444 }
10445
10446 let nonce = std::time::SystemTime::now()
10447 .duration_since(std::time::UNIX_EPOCH)
10448 .unwrap_or_default()
10449 .as_nanos();
10450 let temp_name = format!(
10451 ".dbmd-pull-{}-{nonce}-{}",
10452 std::process::id(),
10453 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
10454 );
10455 let temp = c_name(temp_name.as_bytes(), path)?;
10456 let fd = unsafe {
10457 libc::openat(
10458 directory.as_raw_fd(),
10459 temp.as_ptr(),
10460 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10461 0o600,
10462 )
10463 };
10464 if fd < 0 {
10465 return Err(std::io::Error::last_os_error().into());
10466 }
10467 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
10468 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
10469 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10470 return Err(error.into());
10471 }
10472 drop(file);
10473 let renamed = unsafe {
10474 libc::renameat(
10475 directory.as_raw_fd(),
10476 temp.as_ptr(),
10477 directory.as_raw_fd(),
10478 leaf_name.as_ptr(),
10479 )
10480 };
10481 if renamed != 0 {
10482 let error = std::io::Error::last_os_error();
10483 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10484 return Err(error.into());
10485 }
10486 directory.sync_all()?;
10487 }
10488 root.sync_all()?;
10489 Ok(())
10490}
10491
10492#[cfg(unix)]
10493fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
10494 use std::os::fd::{AsRawFd as _, FromRawFd as _};
10495
10496 let path = &entry.path;
10497 let components: Vec<&str> = path.split('/').collect();
10498 let (leaf, parents) = components
10499 .split_last()
10500 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10501 let mut directory = root.try_clone()?;
10502 for component in parents {
10503 let name = c_name(component.as_bytes(), path)?;
10504 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10505 if made != 0 {
10506 let error = std::io::Error::last_os_error();
10507 if error.raw_os_error() != Some(libc::EEXIST) {
10508 return Err(error.into());
10509 }
10510 }
10511 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10512 }
10513 let leaf_name = c_name(leaf.as_bytes(), path)?;
10514 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
10515 if unsafe {
10516 libc::fstatat(
10517 directory.as_raw_fd(),
10518 leaf_name.as_ptr(),
10519 &mut existing,
10520 libc::AT_SYMLINK_NOFOLLOW,
10521 )
10522 } == 0
10523 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
10524 {
10525 return Err(LinkError::UnsafePath { path: path.clone() });
10526 }
10527 let nonce = SystemTime::now()
10528 .duration_since(UNIX_EPOCH)
10529 .unwrap_or_default()
10530 .as_nanos();
10531 let temp_name = format!(
10532 ".dbmd-pull-{}-{nonce}-{}",
10533 std::process::id(),
10534 content_sha256(path.as_bytes())
10535 );
10536 let temp = c_name(temp_name.as_bytes(), path)?;
10537 let fd = unsafe {
10538 libc::openat(
10539 directory.as_raw_fd(),
10540 temp.as_ptr(),
10541 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10542 0o600,
10543 )
10544 };
10545 if fd < 0 {
10546 return Err(std::io::Error::last_os_error().into());
10547 }
10548 let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
10549 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
10550 let mut digest = Sha256::new();
10551 let mut total = 0_u64;
10552 let mut buffer = [0_u8; 64 * 1024];
10553 let copied = (|| -> std::io::Result<()> {
10554 loop {
10555 let read = input.read(&mut buffer)?;
10556 if read == 0 {
10557 break;
10558 }
10559 total = total.saturating_add(read as u64);
10560 if total > entry.bytes {
10561 return Err(std::io::Error::new(
10562 std::io::ErrorKind::InvalidData,
10563 "staged sync source grew beyond its verified length",
10564 ));
10565 }
10566 digest.update(&buffer[..read]);
10567 output.write_all(&buffer[..read])?;
10568 }
10569 Ok(())
10570 })();
10571 if let Err(error) = copied {
10572 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10573 return Err(error.into());
10574 }
10575 drop(output);
10576 if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
10577 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10578 return Err(invalid_feed(
10579 "private staged sync source failed final integrity verification",
10580 ));
10581 }
10582 if unsafe {
10583 libc::renameat(
10584 directory.as_raw_fd(),
10585 temp.as_ptr(),
10586 directory.as_raw_fd(),
10587 leaf_name.as_ptr(),
10588 )
10589 } != 0
10590 {
10591 let error = std::io::Error::last_os_error();
10592 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10593 return Err(error.into());
10594 }
10595 Ok(())
10596}
10597
10598#[cfg(unix)]
10599fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
10600 use std::os::fd::{AsRawFd as _, FromRawFd as _};
10601
10602 let path = &entry.path;
10603 let components: Vec<&str> = path.split('/').collect();
10604 let (leaf, parents) = components
10605 .split_last()
10606 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10607 let mut directory = root.try_clone()?;
10608 for component in parents {
10609 directory = open_dir_at(
10610 directory.as_raw_fd(),
10611 &c_name(component.as_bytes(), path)?,
10612 path,
10613 )?;
10614 }
10615 let leaf = c_name(leaf.as_bytes(), path)?;
10616 let fd = unsafe {
10617 libc::openat(
10618 directory.as_raw_fd(),
10619 leaf.as_ptr(),
10620 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10621 )
10622 };
10623 if fd < 0 {
10624 return Err(std::io::Error::last_os_error().into());
10625 }
10626 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
10627 if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
10628 return Err(invalid_feed(
10629 "private pull stage changed before its durability barrier",
10630 ));
10631 }
10632 file.sync_all()?;
10633 Ok(())
10634}
10635
10636#[cfg(unix)]
10637fn run_pull_source_workers(
10638 root: &std::fs::File,
10639 entries: &[V2StagedFile],
10640 operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
10641) -> LinkResult<()> {
10642 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10643
10644 let next = AtomicUsize::new(0);
10645 let failed = AtomicBool::new(false);
10646 let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
10647 let mut first_error = None;
10648 std::thread::scope(|scope| {
10649 let (sender, receiver) = std::sync::mpsc::channel();
10650 for _ in 0..worker_count {
10651 let sender = sender.clone();
10652 let next = &next;
10653 let failed = &failed;
10654 scope.spawn(move || {
10655 while !failed.load(Ordering::Acquire) {
10656 let index = next.fetch_add(1, Ordering::Relaxed);
10657 let Some(entry) = entries.get(index) else {
10658 break;
10659 };
10660 let result = operation(root, entry);
10661 if result.is_err() {
10662 failed.store(true, Ordering::Release);
10663 }
10664 if sender.send(result).is_err() {
10665 break;
10666 }
10667 }
10668 });
10669 }
10670 drop(sender);
10671 for result in receiver {
10672 if let Err(error) = result {
10673 if first_error.is_none() {
10674 first_error = Some(error);
10675 }
10676 }
10677 }
10678 });
10679 if let Some(error) = first_error {
10680 return Err(error);
10681 }
10682 if next.load(Ordering::Relaxed) < entries.len() {
10683 return Err(invalid_feed(
10684 "a bounded pull worker stopped before reporting every file",
10685 ));
10686 }
10687 Ok(())
10688}
10689
10690#[cfg(unix)]
10691fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
10692 use std::os::fd::AsRawFd as _;
10693
10694 for name in directory_entry_names(root)? {
10695 if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
10696 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
10697 let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
10698 sync_pull_directory_tree(&child, &child_display)?;
10699 }
10700 }
10701 root.sync_all()?;
10702 Ok(())
10703}
10704
10705#[cfg(unix)]
10706fn write_pull_sources_beneath_dir(
10707 root: &std::fs::File,
10708 entries: &[V2StagedFile],
10709) -> LinkResult<()> {
10710 run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
10717 run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
10718 sync_pull_directory_tree(root, "v2 pull stage")
10719}
10720
10721#[cfg(unix)]
10722fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
10723 use std::os::fd::AsRawFd as _;
10724 for path in paths {
10725 if !safe_store_rel_path(path) {
10726 return Err(LinkError::UnsafePath { path: path.clone() });
10727 }
10728 let components = path.split('/').collect::<Vec<_>>();
10729 let Some((leaf, parents)) = components.split_last() else {
10730 return Err(LinkError::UnsafePath { path: path.clone() });
10731 };
10732 let mut directory = root.try_clone()?;
10733 let mut missing = false;
10734 for component in parents {
10735 let name = c_name(component.as_bytes(), path)?;
10736 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
10737 None => {
10738 missing = true;
10739 break;
10740 }
10741 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
10742 Some(true) => {
10743 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10744 }
10745 }
10746 }
10747 if missing {
10748 continue;
10749 }
10750 let leaf = c_name(leaf.as_bytes(), path)?;
10751 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
10752 None => {}
10753 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
10754 Some(false) => {
10755 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
10756 return Err(std::io::Error::last_os_error().into());
10757 }
10758 directory.sync_all()?;
10759 }
10760 }
10761 }
10762 Ok(())
10763}
10764
10765#[cfg(unix)]
10766fn install_pulled_delta(
10767 dest: &Path,
10768 entries: &[(String, Vec<u8>)],
10769 deleted: &[String],
10770 rebuild_indexes: bool,
10771) -> LinkResult<()> {
10772 use ring::rand::SecureRandom as _;
10773 use std::os::fd::AsRawFd as _;
10774 use std::os::unix::ffi::OsStrExt as _;
10775
10776 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10777 let name = dest
10778 .file_name()
10779 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10780 .ok_or_else(|| LinkError::UnsafePath {
10781 path: dest.display().to_string(),
10782 })?;
10783 let parent_dir = open_or_create_dir_nofollow(parent)?;
10784 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10785 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10786 None => false,
10787 Some(true) => true,
10788 Some(false) => {
10789 return Err(LinkError::UnsafePath {
10790 path: dest.display().to_string(),
10791 });
10792 }
10793 };
10794
10795 let mut nonce = [0_u8; 16];
10796 ring::rand::SystemRandom::new()
10797 .fill(&mut nonce)
10798 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10799 let stage_label = format!(
10800 ".{}.dbmd-pull-stage-{}",
10801 name.to_string_lossy(),
10802 URL_SAFE_NO_PAD.encode(nonce)
10803 );
10804 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10805 let stage_dir = create_dir_exclusive_at(
10806 parent_dir.as_raw_fd(),
10807 &stage_name,
10808 &dest.display().to_string(),
10809 )?;
10810
10811 let prepared = (|| -> LinkResult<()> {
10812 if dest_exists {
10813 let live = open_dir_at(
10814 parent_dir.as_raw_fd(),
10815 &dest_name,
10816 &dest.display().to_string(),
10817 )?;
10818 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10819 }
10820 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10821 write_pull_entries_beneath_dir(&stage_dir, entries)?;
10822 if rebuild_indexes {
10823 let stage_store =
10824 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10825 .map_err(|error| LinkError::InvalidPack {
10826 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10827 })?;
10828 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10829 LinkError::InvalidPack {
10830 message: format!("could not materialize v2 local catalogs: {error}"),
10831 }
10832 })?;
10833 }
10834 stage_dir.sync_all()?;
10835 Ok(())
10836 })();
10837 if let Err(error) = prepared {
10838 let _ = remove_tree_at(
10839 parent_dir.as_raw_fd(),
10840 &stage_name,
10841 &dest.display().to_string(),
10842 );
10843 return Err(error);
10844 }
10845
10846 if let Err(error) =
10847 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10848 {
10849 let _ = remove_tree_at(
10850 parent_dir.as_raw_fd(),
10851 &stage_name,
10852 &dest.display().to_string(),
10853 );
10854 return Err(error);
10855 }
10856 parent_dir.sync_all()?;
10857 if dest_exists {
10858 let _ = remove_tree_at(
10862 parent_dir.as_raw_fd(),
10863 &stage_name,
10864 &dest.display().to_string(),
10865 );
10866 let _ = parent_dir.sync_all();
10867 }
10868 Ok(())
10869}
10870
10871#[cfg(unix)]
10872fn install_pulled_delta_sources(
10873 dest: &Path,
10874 entries: &[V2StagedFile],
10875 deleted: &[String],
10876 rebuild_indexes: bool,
10877 _previous: Option<&V2SyncBaseline>,
10878 _next: &V2VerifiedHead,
10879) -> LinkResult<()> {
10880 use ring::rand::SecureRandom as _;
10881 use std::os::fd::AsRawFd as _;
10882 use std::os::unix::ffi::OsStrExt as _;
10883
10884 if let Ok(store) = Store::open_strict(dest) {
10888 return install_established_v2_delta(
10889 store,
10890 entries,
10891 deleted,
10892 rebuild_indexes,
10893 _previous,
10894 _next,
10895 );
10896 }
10897
10898 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10899 let name = dest
10900 .file_name()
10901 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10902 .ok_or_else(|| LinkError::UnsafePath {
10903 path: dest.display().to_string(),
10904 })?;
10905 let parent_dir = open_or_create_dir_nofollow(parent)?;
10906 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10907 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10908 None => false,
10909 Some(true) => true,
10910 Some(false) => {
10911 return Err(LinkError::UnsafePath {
10912 path: dest.display().to_string(),
10913 })
10914 }
10915 };
10916 let mut nonce = [0_u8; 16];
10917 ring::rand::SystemRandom::new()
10918 .fill(&mut nonce)
10919 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10920 let stage_label = format!(
10921 ".{}.dbmd-pull-stage-{}",
10922 name.to_string_lossy(),
10923 URL_SAFE_NO_PAD.encode(nonce)
10924 );
10925 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10926 let stage_dir = create_dir_exclusive_at(
10927 parent_dir.as_raw_fd(),
10928 &stage_name,
10929 &dest.display().to_string(),
10930 )?;
10931 let prepared = (|| -> LinkResult<()> {
10932 if dest_exists {
10933 let live = open_dir_at(
10934 parent_dir.as_raw_fd(),
10935 &dest_name,
10936 &dest.display().to_string(),
10937 )?;
10938 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10939 }
10940 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10941 write_pull_sources_beneath_dir(&stage_dir, entries)?;
10942 if rebuild_indexes {
10943 let stage_store =
10944 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10945 .map_err(|error| LinkError::InvalidPack {
10946 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10947 })?;
10948 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10949 LinkError::InvalidPack {
10950 message: format!("could not materialize v2 local catalogs: {error}"),
10951 }
10952 })?;
10953 }
10954 stage_dir.sync_all()?;
10955 Ok(())
10956 })();
10957 if let Err(error) = prepared {
10958 let _ = remove_tree_at(
10959 parent_dir.as_raw_fd(),
10960 &stage_name,
10961 &dest.display().to_string(),
10962 );
10963 return Err(error);
10964 }
10965 if let Err(error) =
10966 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10967 {
10968 let _ = remove_tree_at(
10969 parent_dir.as_raw_fd(),
10970 &stage_name,
10971 &dest.display().to_string(),
10972 );
10973 return Err(error);
10974 }
10975 parent_dir.sync_all()?;
10976 if dest_exists {
10977 let _ = remove_tree_at(
10978 parent_dir.as_raw_fd(),
10979 &stage_name,
10980 &dest.display().to_string(),
10981 );
10982 let _ = parent_dir.sync_all();
10983 }
10984 Ok(())
10985}
10986
10987#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10988struct V2PullCoordinate {
10989 head_seq: Option<u64>,
10990 commit_hash: Option<String>,
10991 view_kind: Option<String>,
10992 view_revision: Option<String>,
10993}
10994
10995#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10996struct V2PullFileCoordinate {
10997 sha256: String,
10998 bytes: u64,
10999}
11000
11001#[derive(Debug, Clone, Deserialize, Serialize)]
11002struct V2PullJournalEntry {
11003 path: String,
11004 old: Option<V2PullFileCoordinate>,
11005 new: Option<V2PullFileCoordinate>,
11006 backup: Option<String>,
11007}
11008
11009#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
11010#[serde(rename_all = "snake_case")]
11011enum V2PullPhase {
11012 Preparing,
11013 Ready,
11014}
11015
11016#[derive(Debug, Clone, Deserialize, Serialize)]
11017struct V2PullJournal {
11018 v: u8,
11019 phase: V2PullPhase,
11020 brain: String,
11021 previous: V2PullCoordinate,
11022 next: V2PullCoordinate,
11023 backup_dir: String,
11024 entries: Vec<V2PullJournalEntry>,
11025}
11026
11027const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
11028
11029fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
11030 V2PullCoordinate {
11031 head_seq: baseline.and_then(|value| value.head_seq),
11032 commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
11033 view_kind: baseline.and_then(|value| value.view_kind.clone()),
11034 view_revision: baseline.and_then(|value| value.view_revision.clone()),
11035 }
11036}
11037
11038fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
11039 V2PullCoordinate {
11040 head_seq: head.pointer.as_ref().map(|value| value.seq),
11041 commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
11042 view_kind: Some(head.view_kind.clone()),
11043 view_revision: Some(head.view_revision.clone()),
11044 }
11045}
11046
11047fn v2_pull_file_coordinate(
11048 store: &Store,
11049 path: &str,
11050 limit: u64,
11051) -> LinkResult<Option<V2PullFileCoordinate>> {
11052 let file = match store.open_regular(Path::new(path)) {
11053 Ok(file) => file,
11054 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11055 Err(error) => return Err(error.into()),
11056 };
11057 let bytes = file.metadata()?.len();
11058 if bytes > limit || bytes > MAX_STORE_BYTES {
11059 return Err(invalid_feed(
11060 "pull transaction file exceeds its declared bound",
11061 ));
11062 }
11063 Ok(Some(V2PullFileCoordinate {
11064 sha256: content_sha256_reader(file)?,
11065 bytes,
11066 }))
11067}
11068
11069fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
11070 let mut bytes = serde_json::to_vec_pretty(journal)
11071 .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
11072 bytes.push(b'\n');
11073 Ok(bytes)
11074}
11075
11076fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
11077 let backup_prefix = ".dbmd/pull-backup-";
11078 let suffix = journal
11079 .backup_dir
11080 .strip_prefix(backup_prefix)
11081 .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
11082 let mut paths = std::collections::BTreeSet::new();
11083 if journal.v != 1
11084 || !crate::ulid::is_ulid(&journal.brain)
11085 || !crate::ulid::is_ulid(suffix)
11086 || journal.entries.is_empty()
11087 || journal.entries.len() > MAX_PUSH_FILES + 4
11088 || journal.previous == journal.next
11089 {
11090 return Err(invalid_feed("v2 pull journal failed validation"));
11091 }
11092 for (index, entry) in journal.entries.iter().enumerate() {
11093 if !safe_store_rel_path(&entry.path)
11094 || entry.path == V2_PULL_JOURNAL
11095 || entry.path.starts_with(backup_prefix)
11096 || !paths.insert(entry.path.clone())
11097 || (entry.old.is_none() && entry.new.is_none())
11098 || entry
11099 .old
11100 .iter()
11101 .chain(entry.new.iter())
11102 .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
11103 || entry.backup.as_deref()
11104 != entry
11105 .old
11106 .as_ref()
11107 .map(|_| format!("{index:08x}"))
11108 .as_deref()
11109 {
11110 return Err(invalid_feed("v2 pull journal entry failed validation"));
11111 }
11112 }
11113 Ok(())
11114}
11115
11116fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
11117 #[cfg(unix)]
11118 {
11119 use std::os::unix::fs::PermissionsExt as _;
11120 match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
11121 Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
11122 return Err(invalid_feed(
11123 "v2 pull journal is accessible to group/other; set mode 0600",
11124 ));
11125 }
11126 Ok(_) => {}
11127 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11128 Err(error) => return Err(error.into()),
11129 }
11130 }
11131 let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
11132 Ok(bytes) => bytes,
11133 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11134 Err(error) => return Err(error.into()),
11135 };
11136 let journal: V2PullJournal =
11137 serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
11138 validate_v2_pull_journal(&journal)?;
11139 Ok(Some(journal))
11140}
11141
11142fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
11143 match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
11147 Ok(()) => {}
11148 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
11149 Err(error) => return Err(error.into()),
11150 }
11151 match store.remove_private_tree(Path::new(&journal.backup_dir)) {
11152 Ok(()) => Ok(()),
11153 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
11154 Err(error) => Err(error.into()),
11155 }
11156}
11157
11158fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
11159 let names = match store.directory_names(Path::new(".dbmd")) {
11160 Ok(names) => names,
11161 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
11162 Err(error) => return Err(error.into()),
11163 };
11164 for name in names {
11165 let Some(name) = name.to_str() else {
11166 continue;
11167 };
11168 let Some(suffix) = name.strip_prefix("pull-backup-") else {
11169 continue;
11170 };
11171 if crate::ulid::is_ulid(suffix) {
11172 store.remove_private_tree(&Path::new(".dbmd").join(name))?;
11173 }
11174 }
11175 Ok(())
11176}
11177
11178fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
11179 for entry in &journal.entries {
11181 let limit = entry
11182 .old
11183 .as_ref()
11184 .into_iter()
11185 .chain(entry.new.iter())
11186 .map(|value| value.bytes)
11187 .max()
11188 .unwrap_or(0);
11189 let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
11190 if current != entry.old && current != entry.new {
11191 return Err(LinkError::InvalidPack {
11192 message: format!(
11193 "cannot recover interrupted pull because `{}` changed afterward",
11194 entry.path
11195 ),
11196 });
11197 }
11198 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
11199 let path = Path::new(&journal.backup_dir).join(backup);
11200 let file = store.open_regular(&path)?;
11201 if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
11202 return Err(invalid_feed("v2 pull recovery backup failed verification"));
11203 }
11204 }
11205 }
11206 for entry in journal.entries.iter().rev() {
11207 match (&entry.old, &entry.backup) {
11208 (Some(old), Some(backup)) => {
11209 let bytes =
11210 store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
11211 store.write_atomic(Path::new(&entry.path), &bytes)?;
11212 }
11213 (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
11214 store.remove_file(Path::new(&entry.path))?;
11215 }
11216 (None, None) => {}
11217 _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
11218 }
11219 }
11220 crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
11221 message: format!("could not rebuild catalogs after pull recovery: {error}"),
11222 })?;
11223 cleanup_v2_pull_journal(store, journal)
11224}
11225
11226fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
11227 let Ok(store) = Store::open_strict(dest) else {
11228 return Ok(());
11229 };
11230 if let Some(journal) = load_v2_pull_journal(&store)? {
11231 if journal.brain != brain {
11232 return Err(invalid_feed("v2 pull journal belongs to another brain"));
11233 }
11234 if journal.phase == V2PullPhase::Preparing {
11235 cleanup_v2_pull_journal(&store, &journal)?;
11236 } else {
11237 let baseline = load_v2_baseline(cfg, brain, dest)?;
11238 let current = v2_pull_baseline_coordinate(baseline.as_ref());
11239 if current == journal.next {
11240 cleanup_v2_pull_journal(&store, &journal)?;
11241 } else {
11242 if current != journal.previous {
11243 return Err(invalid_feed(
11244 "cannot recover interrupted pull because its baseline changed afterward",
11245 ));
11246 }
11247 rollback_v2_pull(&store, &journal)?;
11248 }
11249 }
11250 }
11251 prune_orphan_v2_pull_backups(&store)
11256}
11257
11258fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
11259 let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
11260 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
11261 })?;
11262 if let Some(journal) = load_v2_pull_journal(&store)? {
11263 cleanup_v2_pull_journal(&store, &journal)?;
11264 }
11265 Ok(())
11266}
11267
11268#[cfg(windows)]
11269fn install_windows_initial_sources(
11270 dest: &Path,
11271 entries: &[V2StagedFile],
11272 rebuild_indexes: bool,
11273) -> LinkResult<()> {
11274 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
11275 let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
11276 path: dest.display().to_string(),
11277 })?;
11278 let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
11279 if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
11280 return Err(LinkError::UnsafePath {
11281 path: dest.display().to_string(),
11282 });
11283 }
11284 let stage_name = format!(
11285 ".{}.dbmd-pull-stage-{}",
11286 name.to_string_lossy(),
11287 crate::ulid::mint()
11288 );
11289 let stage_path = parent.join(&stage_name);
11290 let stage_capability =
11291 crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
11292 let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
11293 let prepared = (|| -> LinkResult<()> {
11294 for entry in entries {
11295 let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
11296 if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
11297 return Err(invalid_feed(
11298 "private staged sync source failed final integrity verification",
11299 ));
11300 }
11301 stage.write_atomic(Path::new(&entry.path), &bytes)?;
11302 }
11303 let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
11304 .map_err(|error| LinkError::InvalidPack {
11305 message: format!("v2 staging tree is not a valid db.md store: {error}"),
11306 })?;
11307 if rebuild_indexes {
11308 crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
11309 message: format!("could not materialize v2 local catalogs: {error}"),
11310 })?;
11311 }
11312 Ok(())
11313 })();
11314 if let Err(error) = prepared {
11315 let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
11316 return Err(error);
11317 }
11318 crate::fsx::rename_directory_beneath(
11319 &parent_capability,
11320 Path::new(&stage_name),
11321 Path::new(name),
11322 )?;
11323 Ok(())
11324}
11325
11326fn install_established_v2_delta(
11327 store: Store,
11328 entries: &[V2StagedFile],
11329 deleted: &[String],
11330 rebuild_indexes: bool,
11331 previous: Option<&V2SyncBaseline>,
11332 next: &V2VerifiedHead,
11333) -> LinkResult<()> {
11334 if load_v2_pull_journal(&store)?.is_some() {
11335 return Err(invalid_feed(
11336 "an interrupted pull must be recovered before installing",
11337 ));
11338 }
11339 let mut sources = std::collections::BTreeMap::new();
11340 for entry in entries {
11341 if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
11342 return Err(invalid_feed("pull mutation repeats a path"));
11343 }
11344 }
11345 let mut paths = sources.keys().cloned().collect::<Vec<_>>();
11346 paths.extend(deleted.iter().cloned());
11347 paths.sort();
11348 paths.dedup();
11349 if paths.is_empty() {
11350 return Ok(());
11351 }
11352 let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
11353 let mut journal = V2PullJournal {
11354 v: 1,
11355 phase: V2PullPhase::Preparing,
11356 brain: next.brain_id.clone(),
11357 previous: v2_pull_baseline_coordinate(previous),
11358 next: v2_pull_head_coordinate(next),
11359 backup_dir: backup_dir.clone(),
11360 entries: Vec::with_capacity(paths.len()),
11361 };
11362 for path in &paths {
11363 let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
11364 let new = sources.get(path).map(|entry| V2PullFileCoordinate {
11365 sha256: entry.sha256.clone(),
11366 bytes: entry.bytes,
11367 });
11368 if old == new {
11369 continue;
11370 }
11371 let index = journal.entries.len();
11372 journal.entries.push(V2PullJournalEntry {
11373 path: path.clone(),
11374 backup: old.as_ref().map(|_| format!("{index:08x}")),
11375 old,
11376 new,
11377 });
11378 }
11379 if journal.entries.is_empty() {
11380 return Ok(());
11381 }
11382 let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
11383 entry
11384 .old
11385 .as_ref()
11386 .map_or(Some(total), |old| total.checked_add(old.bytes))
11387 });
11388 if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
11389 return Err(LinkError::InvalidPack {
11390 message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
11391 });
11392 }
11393 validate_v2_pull_journal(&journal)?;
11394 store.write_private_atomic_new(
11395 Path::new(V2_PULL_JOURNAL),
11396 &v2_pull_journal_bytes(&journal)?,
11397 )?;
11398 let prepared = (|| -> LinkResult<()> {
11399 store.create_private_dir_all(Path::new(&backup_dir))?;
11400 for entry in &journal.entries {
11401 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
11402 let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
11403 if content_sha256(&bytes) != old.sha256 {
11404 return Err(invalid_feed("live pull source changed during backup"));
11405 }
11406 store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
11407 }
11408 }
11409 journal.phase = V2PullPhase::Ready;
11410 store.write_private_atomic(
11411 Path::new(V2_PULL_JOURNAL),
11412 &v2_pull_journal_bytes(&journal)?,
11413 )?;
11414 Ok(())
11415 })();
11416 if let Err(error) = prepared {
11417 let cleanup = cleanup_v2_pull_journal(&store, &journal);
11418 return match cleanup {
11419 Ok(()) => Err(error),
11420 Err(cleanup) => Err(LinkError::InvalidPack {
11421 message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
11422 }),
11423 };
11424 }
11425 let installed = (|| -> LinkResult<()> {
11426 for entry in &journal.entries {
11427 if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
11428 return Err(LinkError::InvalidPack {
11429 message: format!("local path `{}` changed during pull", entry.path),
11430 });
11431 }
11432 if let Some(source) = sources.get(&entry.path) {
11433 let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
11434 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
11435 return Err(invalid_feed(
11436 "private staged sync source failed final integrity verification",
11437 ));
11438 }
11439 store.write_atomic(Path::new(&entry.path), &bytes)?;
11440 } else if entry.old.is_some() {
11441 store.remove_file(Path::new(&entry.path))?;
11442 }
11443 }
11444 if rebuild_indexes {
11445 crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
11446 message: format!("could not materialize v2 local catalogs: {error}"),
11447 })?;
11448 }
11449 Ok(())
11450 })();
11451 if let Err(error) = installed {
11452 return match rollback_v2_pull(&store, &journal) {
11453 Ok(()) => Err(error),
11454 Err(rollback) => Err(LinkError::InvalidPack {
11455 message: format!("{error}; durable pull rollback also failed: {rollback}"),
11456 }),
11457 };
11458 }
11459 Ok(())
11460}
11461
11462#[cfg(windows)]
11463fn install_pulled_delta_sources(
11464 dest: &Path,
11465 entries: &[V2StagedFile],
11466 deleted: &[String],
11467 rebuild_indexes: bool,
11468 previous: Option<&V2SyncBaseline>,
11469 next: &V2VerifiedHead,
11470) -> LinkResult<()> {
11471 match Store::open_strict(dest) {
11472 Ok(store) => {
11473 install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
11474 }
11475 Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
11476 }
11477}
11478
11479#[cfg(not(any(unix, windows)))]
11480fn install_pulled_delta_sources(
11481 _dest: &Path,
11482 _entries: &[V2StagedFile],
11483 _deleted: &[String],
11484 _rebuild_indexes: bool,
11485 _previous: Option<&V2SyncBaseline>,
11486 _next: &V2VerifiedHead,
11487) -> LinkResult<()> {
11488 Err(LinkError::UnsupportedPlatform {
11489 operation: "atomic v2 pull install",
11490 })
11491}
11492
11493#[cfg(unix)]
11494fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
11495 install_pulled_delta(dest, entries, &[], false)
11496}
11497
11498#[cfg(not(windows))]
11499fn is_safe_slug(slug: &str) -> bool {
11500 !slug.is_empty()
11501 && slug.len() <= 63
11502 && !slug.starts_with('-')
11503 && !slug.ends_with('-')
11504 && slug
11505 .bytes()
11506 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
11507}
11508
11509fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
11510 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
11511}
11512
11513fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
11514 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
11515}
11516
11517fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
11518 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
11519}
11520
11521fn preflight_zip_central_directory(
11522 bytes: &[u8],
11523 offset: usize,
11524 size: usize,
11525 count: u64,
11526) -> LinkResult<()> {
11527 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
11528 let end = offset
11529 .checked_add(size)
11530 .filter(|end| *end <= bytes.len())
11531 .ok_or_else(|| LinkError::InvalidPack {
11532 message: "ZIP central directory is out of bounds".to_string(),
11533 })?;
11534 let mut cursor = offset;
11535 for _ in 0..count {
11536 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
11537 return Err(LinkError::InvalidPack {
11538 message: "ZIP central directory entry count is inconsistent".to_string(),
11539 });
11540 }
11541 if le_u16(bytes, cursor + 34) != Some(0) {
11542 return Err(LinkError::InvalidPack {
11543 message: "multi-disk ZIP archives are not supported".to_string(),
11544 });
11545 }
11546 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
11547 total.checked_add(le_u16(bytes, cursor + at)? as usize)
11548 });
11549 cursor = cursor
11550 .checked_add(46)
11551 .and_then(|fixed| fixed.checked_add(variable?))
11552 .filter(|cursor| *cursor <= end)
11553 .ok_or_else(|| LinkError::InvalidPack {
11554 message: "ZIP central directory entry is truncated".to_string(),
11555 })?;
11556 }
11557 if cursor != end {
11558 return Err(LinkError::InvalidPack {
11559 message: "ZIP central directory size is inconsistent".to_string(),
11560 });
11561 }
11562 Ok(())
11563}
11564
11565fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
11569 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
11570 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
11571 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
11572 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
11573 let eocd = bytes[search_start..]
11574 .windows(4)
11575 .rposition(|window| window == EOCD_SIG)
11576 .map(|offset| search_start + offset)
11577 .ok_or_else(|| LinkError::InvalidPack {
11578 message: "ZIP has no end-of-central-directory record".to_string(),
11579 })?;
11580 let invalid_end = || LinkError::InvalidPack {
11581 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
11582 };
11583 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
11584 if eocd
11585 .checked_add(22)
11586 .and_then(|end| end.checked_add(comment_len))
11587 != Some(bytes.len())
11588 {
11589 return Err(invalid_end());
11593 }
11594 let disk = le_u16(bytes, eocd + 4);
11595 let central_disk = le_u16(bytes, eocd + 6);
11596 if disk != Some(0) || central_disk != Some(0) {
11597 return Err(LinkError::InvalidPack {
11598 message: "multi-disk ZIP archives are not supported".to_string(),
11599 });
11600 }
11601 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
11602 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
11603 if entries_on_disk != ordinary {
11604 return Err(LinkError::InvalidPack {
11605 message: "multi-disk ZIP archives are not supported".to_string(),
11606 });
11607 }
11608 let zip64_locator = eocd
11609 .checked_sub(20)
11610 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
11611 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
11612 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
11613 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
11614 if central_offset
11615 .checked_add(central_size)
11616 .filter(|end| *end == eocd)
11617 .is_none()
11618 {
11619 return Err(invalid_end());
11620 }
11621 (ordinary as u64, central_offset, central_size)
11622 } else {
11623 let Some(locator) = zip64_locator else {
11624 return Err(invalid_end());
11625 };
11626 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
11627 return Err(LinkError::InvalidPack {
11628 message: "multi-disk ZIP64 archives are not supported".to_string(),
11629 });
11630 }
11631 let record = le_u64(bytes, locator + 8)
11632 .and_then(|offset| usize::try_from(offset).ok())
11633 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
11634 .ok_or_else(|| LinkError::InvalidPack {
11635 message: "ZIP64 archive has an invalid end record".to_string(),
11636 })?;
11637 let record_size = le_u64(bytes, record + 4)
11638 .and_then(|size| usize::try_from(size).ok())
11639 .filter(|size| *size >= 44)
11640 .ok_or_else(invalid_end)?;
11641 if record
11642 .checked_add(12)
11643 .and_then(|end| end.checked_add(record_size))
11644 != Some(locator)
11645 || le_u32(bytes, record + 16) != Some(0)
11646 || le_u32(bytes, record + 20) != Some(0)
11647 {
11648 return Err(invalid_end());
11649 }
11650 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
11651 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
11652 let central_size = le_u64(bytes, record + 40)
11653 .and_then(|size| usize::try_from(size).ok())
11654 .ok_or_else(invalid_end)?;
11655 let central_offset = le_u64(bytes, record + 48)
11656 .and_then(|offset| usize::try_from(offset).ok())
11657 .ok_or_else(invalid_end)?;
11658 if zip64_on_disk != zip64_total
11659 || central_offset
11660 .checked_add(central_size)
11661 .filter(|end| *end == record)
11662 .is_none()
11663 {
11664 return Err(invalid_end());
11665 }
11666 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
11667 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
11668 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
11669 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
11670 {
11671 return Err(invalid_end());
11672 }
11673 (zip64_total, central_offset, central_size)
11674 };
11675 if count == 0 || count > max_entries as u64 {
11676 return Err(LinkError::InvalidPack {
11677 message: format!("invalid file count {count}"),
11678 });
11679 }
11680 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
11681 Ok(())
11682}
11683
11684fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
11685 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
11686 let mut archive =
11687 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
11688 message: format!("ZIP parse failed: {err}"),
11689 })?;
11690 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
11691 return Err(LinkError::InvalidPack {
11692 message: format!("invalid file count {}", archive.len()),
11693 });
11694 }
11695 let mut total = 0u64;
11696 let mut seen = std::collections::HashSet::new();
11697 let mut entries = Vec::with_capacity(archive.len());
11698 for index in 0..archive.len() {
11699 let mut file = archive
11700 .by_index(index)
11701 .map_err(|err| LinkError::InvalidPack {
11702 message: format!("ZIP entry failed: {err}"),
11703 })?;
11704 if file.is_dir() {
11705 continue;
11706 }
11707 let path = file.name().to_string();
11708 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
11709 return Err(LinkError::UnsafePath { path });
11710 }
11711 if file
11712 .unix_mode()
11713 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
11714 {
11715 return Err(LinkError::InvalidPack {
11716 message: format!("non-file entry `{path}`"),
11717 });
11718 }
11719 if !seen.insert(path.clone()) {
11720 return Err(LinkError::InvalidPack {
11721 message: format!("duplicate path `{path}`"),
11722 });
11723 }
11724 let remaining = MAX_STORE_BYTES.saturating_sub(total);
11725 if file.size() > remaining {
11726 return Err(LinkError::InvalidPack {
11727 message: "expanded content exceeds the 512 MB limit".to_string(),
11728 });
11729 }
11730 let mut content = Vec::new();
11731 (&mut file)
11732 .take(remaining + 1)
11733 .read_to_end(&mut content)
11734 .map_err(|err| LinkError::InvalidPack {
11735 message: format!("could not decompress `{path}`: {err}"),
11736 })?;
11737 if content.len() as u64 > remaining {
11738 return Err(LinkError::InvalidPack {
11739 message: "expanded content exceeds the 512 MB limit".to_string(),
11740 });
11741 }
11742 if content.len() as u64 != file.size() {
11743 return Err(LinkError::InvalidPack {
11744 message: format!("length mismatch for `{path}`"),
11745 });
11746 }
11747 total += content.len() as u64;
11748 entries.push((path, content));
11749 }
11750 if entries.is_empty() {
11751 return Err(LinkError::InvalidPack {
11752 message: "pack contains no files".to_string(),
11753 });
11754 }
11755 Ok(entries)
11756}
11757
11758fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
11759 let mut expected = std::collections::BTreeMap::new();
11760 for file in signed {
11761 if !safe_store_rel_path(&file.path) {
11762 return Err(LinkError::UnsafePath {
11763 path: file.path.clone(),
11764 });
11765 }
11766 if !is_sha256(&file.sha256)
11767 || expected
11768 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11769 .is_some()
11770 {
11771 return Err(invalid_feed(
11772 "signed snapshot manifest contains an invalid or duplicate file",
11773 ));
11774 }
11775 }
11776 if expected.len() != entries.len() {
11777 return Err(invalid_feed(
11778 "downloaded pack file set differs from the signed snapshot manifest",
11779 ));
11780 }
11781 for (path, bytes) in entries {
11782 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
11783 return Err(invalid_feed(format!(
11784 "downloaded pack contains unsigned path `{path}`"
11785 )));
11786 };
11787 if *declared_bytes != bytes.len() as u64
11788 || *sha256 != format!("{:x}", Sha256::digest(bytes))
11789 {
11790 return Err(invalid_feed(format!(
11791 "downloaded file `{path}` differs from its signed manifest"
11792 )));
11793 }
11794 }
11795 Ok(())
11796}
11797
11798pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
11804 require_hardened_filesystem("sync push")?;
11805 preflight_push_ownership(store)?;
11806 let mut out: Vec<(String, String)> = Vec::new();
11807 let mut total = 0u64;
11808
11809 let mut read_text = |rel: &str| -> LinkResult<String> {
11810 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
11811 total = total
11812 .checked_add(bytes.len() as u64)
11813 .ok_or_else(|| LinkError::PushTooLarge {
11814 detail: "uncompressed byte count overflow".to_string(),
11815 })?;
11816 if total > MAX_STORE_BYTES {
11817 return Err(LinkError::PushTooLarge {
11818 detail: format!("{total} uncompressed bytes"),
11819 });
11820 }
11821 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
11822 path: rel.to_string(),
11823 })
11824 };
11825
11826 out.push(("DB.md".to_string(), read_text("DB.md")?));
11827 if store
11828 .regular_file_exists(Path::new("assets.jsonl"))
11829 .unwrap_or(false)
11830 {
11831 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
11832 }
11833 if store
11834 .regular_file_exists(Path::new("log.md"))
11835 .unwrap_or(false)
11836 {
11837 out.push(("log.md".to_string(), read_text("log.md")?));
11838 }
11839 if store.directory_exists(Path::new("log"))? {
11840 for rel in store.walk_regular_files(Path::new("log"))? {
11841 let rel_str = rel.to_string_lossy().replace('\\', "/");
11842 if rel.extension().and_then(std::ffi::OsStr::to_str) != Some("md") {
11843 continue;
11844 }
11845 if !safe_store_rel_path(&rel_str) {
11846 return Err(LinkError::UnsafePath { path: rel_str });
11847 }
11848 let content = read_text(&rel_str)?;
11849 out.push((rel_str, content));
11850 }
11851 }
11852
11853 for rel in store.walk()? {
11854 let rel_str = rel.to_string_lossy().replace('\\', "/");
11855 if !safe_store_rel_path(&rel_str) {
11856 return Err(LinkError::UnsafePath { path: rel_str });
11859 }
11860 let content = read_text(&rel_str)?;
11861 out.push((rel_str, content));
11862 }
11863
11864 out.sort_by(|a, b| a.0.cmp(&b.0));
11865 Ok(out)
11866}
11867
11868fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
11872 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
11873 return Err(LinkError::from(std::io::Error::new(
11874 std::io::ErrorKind::PermissionDenied,
11875 format!("cannot push: nested db.md store at {}", nested.display()),
11876 )));
11877 }
11878
11879 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
11880 return Err(LinkError::from(std::io::Error::new(
11881 std::io::ErrorKind::PermissionDenied,
11882 format!(
11883 "cannot push: {} is a symlink outside the store ownership model",
11884 symlink.display()
11885 ),
11886 )));
11887 }
11888 Ok(())
11889}
11890
11891pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
11897 require_safe_ref(brain)?;
11898 let remote = verified_remote_head(cfg, brain, false)?;
11899 if files.len() > MAX_PUSH_FILES {
11900 return Err(LinkError::PushTooLarge {
11901 detail: format!("{} files", files.len()),
11902 });
11903 }
11904 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
11905 if raw_total > MAX_STORE_BYTES {
11906 return Err(LinkError::PushTooLarge {
11907 detail: format!("{raw_total} uncompressed bytes"),
11908 });
11909 }
11910
11911 if cfg.brain_key.is_none() {
11915 let body = json!({
11916 "files": files
11917 .iter()
11918 .map(|(p, c)| json!({ "path": p, "content": c }))
11919 .collect::<Vec<_>>(),
11920 });
11921 if body.to_string().len() <= MAX_PUSH_BYTES {
11922 let path = format!("/api/hub/brains/{brain}/push");
11923 let pushed = ensure_ok(
11924 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11925 "sync push",
11926 )?;
11927 return Ok(pushed);
11928 }
11929 }
11930
11931 let pack = build_store_pack(files)?;
11932 if pack.len() as u64 > MAX_PACK_BYTES {
11933 return Err(LinkError::PushTooLarge {
11934 detail: format!("{} pack bytes", pack.len()),
11935 });
11936 }
11937 let sha256 = format!("{:x}", Sha256::digest(&pack));
11938 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
11939 if let Some(key) = &cfg.brain_key {
11940 if !remote.head.verified {
11941 return Err(invalid_feed(
11942 "self-custody push requires a fully verified, unscoped feed head",
11943 ));
11944 }
11945 let identity = remote
11946 .identity
11947 .as_ref()
11948 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
11949 let current_multikey = format!("ed25519:{}", identity.fingerprint);
11950 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
11951 return Err(invalid_feed(
11952 "configured brain key is not the verified current brain identity",
11953 ));
11954 }
11955 let next_seq = remote
11958 .head
11959 .seq
11960 .checked_add(1)
11961 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
11962 let mut manifest: Vec<WireFeedFile> = files
11963 .iter()
11964 .map(|(path, content)| WireFeedFile {
11965 path: path.clone(),
11966 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
11967 bytes: content.len() as u64,
11968 })
11969 .collect();
11970 manifest.sort_by(|a, b| a.path.cmp(&b.path));
11971 let ts = crate::now()
11972 .with_timezone(&chrono::Utc)
11973 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
11974 .to_string();
11975 let entry = self_custody_entry(
11976 key,
11977 next_seq,
11978 ts,
11979 &sha256,
11980 &manifest,
11981 remote.head.feed_hash.as_deref(),
11982 )?;
11983 meta["entry"] = Value::String(entry);
11984 }
11985 let presigned = ensure_ok(
11986 request(
11987 cfg,
11988 "POST",
11989 &format!("/api/hub/brains/{brain}/packs/presign"),
11990 Some(&meta),
11991 Auth::Required,
11992 )?,
11993 "prepare pack upload",
11994 )?;
11995 let url = presigned
11996 .get("url")
11997 .and_then(Value::as_str)
11998 .ok_or_else(|| LinkError::InvalidPack {
11999 message: "the hub returned no upload URL".to_string(),
12000 })?;
12001 put_presigned(
12002 cfg,
12003 url,
12004 presigned.get("headers").unwrap_or(&Value::Null),
12005 &pack,
12006 )?;
12007 let committed = ensure_ok(
12008 request(
12009 cfg,
12010 "POST",
12011 &format!("/api/hub/brains/{brain}/packs/commit"),
12012 Some(&meta),
12013 Auth::Required,
12014 )?,
12015 "commit pack",
12016 )?;
12017 Ok(committed)
12018}
12019
12020fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
12021 const LOCAL_HEADER: u32 = 0x0403_4b50;
12022 const CENTRAL_HEADER: u32 = 0x0201_4b50;
12023 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
12024 const VERSION_20: u16 = 20;
12025 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
12026 const UTF8_FLAG: u16 = 1 << 11;
12027 const STORED: u16 = 0;
12028 const DOS_TIME_MIDNIGHT: u16 = 0;
12029 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
12030 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
12031
12032 struct CentralEntry<'a> {
12033 name: &'a [u8],
12034 crc32: u32,
12035 size: u32,
12036 local_offset: u32,
12037 }
12038
12039 fn push_u16(out: &mut Vec<u8>, value: u16) {
12040 out.extend_from_slice(&value.to_le_bytes());
12041 }
12042
12043 fn push_u32(out: &mut Vec<u8>, value: u32) {
12044 out.extend_from_slice(&value.to_le_bytes());
12045 }
12046
12047 if files.is_empty() {
12048 return Err(LinkError::InvalidPack {
12049 message: "cannot create an empty snapshot pack".to_string(),
12050 });
12051 }
12052 if files.len() > u16::MAX as usize {
12053 return Err(LinkError::PushTooLarge {
12054 detail: format!(
12055 "{} files (canonical ZIP32 packs cap at {})",
12056 files.len(),
12057 u16::MAX
12058 ),
12059 });
12060 }
12061
12062 let mut sorted: Vec<_> = files.iter().collect();
12063 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
12064 let mut previous: Option<&str> = None;
12065 for (path, content) in &sorted {
12066 if !safe_store_rel_path(path) {
12067 return Err(LinkError::UnsafePath {
12068 path: (*path).clone(),
12069 });
12070 }
12071 if previous == Some(path.as_str()) {
12072 return Err(LinkError::InvalidPack {
12073 message: format!("duplicate path `{path}`"),
12074 });
12075 }
12076 previous = Some(path.as_str());
12077 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
12078 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
12079 })?;
12080 }
12081
12082 let mut out = Vec::new();
12083 let mut central = Vec::with_capacity(sorted.len());
12084 for (path, content) in sorted {
12085 let name = path.as_bytes();
12086 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
12087 message: format!("ZIP entry name is too long: `{path}`"),
12088 })?;
12089 let bytes = content.as_bytes();
12090 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
12091 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
12092 })?;
12093 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
12094 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
12095 })?;
12096 let crc32 = crc32fast::hash(bytes);
12097
12098 push_u32(&mut out, LOCAL_HEADER);
12101 push_u16(&mut out, VERSION_20);
12102 push_u16(&mut out, UTF8_FLAG);
12103 push_u16(&mut out, STORED);
12104 push_u16(&mut out, DOS_TIME_MIDNIGHT);
12105 push_u16(&mut out, DOS_DATE_1980_01_01);
12106 push_u32(&mut out, crc32);
12107 push_u32(&mut out, size);
12108 push_u32(&mut out, size);
12109 push_u16(&mut out, name_len);
12110 push_u16(&mut out, 0); out.extend_from_slice(name);
12112 out.extend_from_slice(bytes);
12113
12114 central.push(CentralEntry {
12115 name,
12116 crc32,
12117 size,
12118 local_offset,
12119 });
12120 }
12121
12122 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
12123 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
12124 })?;
12125 for entry in ¢ral {
12126 push_u32(&mut out, CENTRAL_HEADER);
12127 push_u16(&mut out, MADE_BY_UNIX_20);
12128 push_u16(&mut out, VERSION_20);
12129 push_u16(&mut out, UTF8_FLAG);
12130 push_u16(&mut out, STORED);
12131 push_u16(&mut out, DOS_TIME_MIDNIGHT);
12132 push_u16(&mut out, DOS_DATE_1980_01_01);
12133 push_u32(&mut out, entry.crc32);
12134 push_u32(&mut out, entry.size);
12135 push_u32(&mut out, entry.size);
12136 push_u16(&mut out, entry.name.len() as u16);
12137 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);
12142 push_u32(&mut out, entry.local_offset);
12143 out.extend_from_slice(entry.name);
12144 }
12145 let central_size = u32::try_from(out.len())
12146 .ok()
12147 .and_then(|end| end.checked_sub(central_offset))
12148 .ok_or_else(|| LinkError::PushTooLarge {
12149 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
12150 })?;
12151 let entry_count = central.len() as u16;
12152
12153 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
12154 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
12157 push_u16(&mut out, entry_count);
12158 push_u32(&mut out, central_size);
12159 push_u32(&mut out, central_offset);
12160 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
12163 return Err(LinkError::PushTooLarge {
12164 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
12165 });
12166 }
12167 Ok(out)
12168}
12169
12170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12176pub enum Capability {
12177 Read,
12179 Write,
12181}
12182
12183impl Capability {
12184 pub fn as_str(self) -> &'static str {
12186 match self {
12187 Capability::Read => "read",
12188 Capability::Write => "write",
12189 }
12190 }
12191}
12192
12193pub fn grant_issue(
12199 cfg: &HubConfig,
12200 brain: &str,
12201 grantee: &str,
12202 can: Capability,
12203 scope: Option<&str>,
12204 until: Option<&str>,
12205) -> LinkResult<Value> {
12206 require_safe_ref(brain)?;
12207 let is_key_grantee = URL_SAFE_NO_PAD
12212 .decode(grantee)
12213 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
12214 .unwrap_or(false);
12215 if let Some(head) = v2_verified_head(cfg, brain)? {
12216 if is_key_grantee {
12217 let scope = scope.unwrap_or("");
12218 let preset = match can {
12219 Capability::Read => "viewer",
12220 Capability::Write => "editor",
12221 };
12222 let entropy = format!(
12223 "{}\0{}\0{}\0{}\0{}\0{}\0{}",
12224 normalized_origin(&cfg.hub)?,
12225 head.brain_id,
12226 head.control_revision,
12227 grantee,
12228 preset,
12229 scope,
12230 until.unwrap_or("")
12231 );
12232 let mut body = json!({
12233 "context": "external",
12234 "expected_control_revision": head.control_revision,
12235 "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
12236 "preset": preset,
12237 "principal_kind": "key",
12238 "public_key": grantee,
12239 "scope": scope,
12240 "scope_kind": "prefix",
12241 });
12242 if let Some(value) = until {
12243 body["expires_at"] = json!(value);
12244 }
12245 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
12246 let response = ensure_ok(
12247 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12248 "v2 grant issue",
12249 )?;
12250 let expected_fingerprint = identity_fingerprint(grantee)?;
12251 if response.get("v").and_then(Value::as_u64) != Some(2)
12252 || response
12253 .get("id")
12254 .and_then(Value::as_str)
12255 .is_none_or(|id| !crate::ulid::is_ulid(id))
12256 || response.get("principal_kind").and_then(Value::as_str) != Some("key")
12257 || response.get("principal_id").and_then(Value::as_str)
12258 != Some(expected_fingerprint.as_str())
12259 || response
12260 .get("control_revision")
12261 .and_then(Value::as_str)
12262 .is_none_or(|value| !is_sha256(value))
12263 {
12264 return Err(invalid_feed(
12265 "v2 grant issue response is not authority-bound",
12266 ));
12267 }
12268 return Ok(response);
12269 }
12270 let mut body = json!({ "email": grantee, "capability": can.as_str() });
12276 if let Some(value) = scope {
12277 body["scopePrefix"] = json!(value);
12278 }
12279 if let Some(value) = until {
12280 body["expiresAt"] = json!(value);
12281 }
12282 let path = format!("/api/hub/brains/{}/grants", head.brain_id);
12283 return ensure_ok(
12284 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12285 "account grant issue",
12286 );
12287 }
12288 let _ = verified_remote_head(cfg, brain, false)?;
12289 let mut body = if is_key_grantee {
12290 json!({ "keySpki": grantee, "capability": can.as_str() })
12291 } else {
12292 json!({ "email": grantee, "capability": can.as_str() })
12293 };
12294 if let Some(s) = scope {
12295 body["scopePrefix"] = json!(s);
12296 }
12297 if let Some(u) = until {
12298 body["expiresAt"] = json!(u);
12299 }
12300 let path = format!("/api/hub/brains/{brain}/grants");
12301 ensure_ok(
12302 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12303 "grant issue",
12304 )
12305}
12306
12307pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
12309 require_safe_ref(brain)?;
12310 if let Some(head) = v2_verified_head(cfg, brain)? {
12311 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
12312 let response = ensure_ok(
12313 request(cfg, "GET", &path, None, Auth::Required)?,
12314 "v2 grant list",
12315 )?;
12316 if response.get("v").and_then(Value::as_u64) != Some(2)
12317 || response.get("control_revision").and_then(Value::as_str)
12318 != Some(head.control_revision.as_str())
12319 || !response.get("grants").is_some_and(Value::is_array)
12320 {
12321 return Err(invalid_feed(
12322 "v2 grant list is not bound to the verified authority",
12323 ));
12324 }
12325 return Ok(response);
12326 }
12327 let _ = verified_remote_head(cfg, brain, false)?;
12328 let path = format!("/api/hub/brains/{brain}/grants");
12329 ensure_ok(
12330 request(cfg, "GET", &path, None, Auth::Required)?,
12331 "grant list",
12332 )
12333}
12334
12335pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
12338 require_safe_ref(brain)?;
12339 require_safe_grant_id(grant_id)?;
12340 if let Some(head) = v2_verified_head(cfg, brain)? {
12341 let entropy = format!(
12342 "{}\0{}\0{}\0{}",
12343 normalized_origin(&cfg.hub)?,
12344 head.brain_id,
12345 head.control_revision,
12346 grant_id
12347 );
12348 let body = json!({
12349 "expected_control_revision": head.control_revision,
12350 "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
12351 });
12352 let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
12353 let response = ensure_ok(
12354 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
12355 "v2 grant revoke",
12356 )?;
12357 if response.get("v").and_then(Value::as_u64) != Some(2)
12358 || response.get("id").and_then(Value::as_str) != Some(grant_id)
12359 || response.get("revoked").and_then(Value::as_bool) != Some(true)
12360 || response
12361 .get("control_revision")
12362 .and_then(Value::as_str)
12363 .is_none_or(|value| !is_sha256(value))
12364 {
12365 return Err(invalid_feed(
12366 "v2 grant revocation response is not authority-bound",
12367 ));
12368 }
12369 return Ok(response);
12370 }
12371 let _ = verified_remote_head(cfg, brain, false)?;
12372 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
12373 ensure_ok(
12374 request(cfg, "DELETE", &path, None, Auth::Required)?,
12375 "grant revoke",
12376 )
12377}
12378
12379#[derive(Debug)]
12384struct VerifiedV2Proposal {
12385 value: Value,
12386 changes: Value,
12387 blobs: Vec<(String, u64, String)>,
12388}
12389
12390fn require_proposal_id(id: &str) -> LinkResult<()> {
12391 if crate::ulid::is_ulid(id) {
12392 Ok(())
12393 } else {
12394 Err(invalid_feed("proposal id is not a lowercase ULID"))
12395 }
12396}
12397
12398fn verified_v2_proposal(
12399 cfg: &HubConfig,
12400 head: &V2VerifiedHead,
12401 proposal_id: &str,
12402) -> LinkResult<VerifiedV2Proposal> {
12403 require_proposal_id(proposal_id)?;
12404 if head.view_kind != "full" {
12405 return Err(invalid_feed(
12406 "proposal review requires a full readable view",
12407 ));
12408 }
12409 let path = format!(
12410 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
12411 head.brain_id
12412 );
12413 let value = ensure_ok(
12414 request_capped(
12415 cfg,
12416 "GET",
12417 &path,
12418 None,
12419 Auth::Required,
12420 MAX_FEED_RESPONSE_BYTES,
12421 )?,
12422 "v2 proposal",
12423 )?;
12424 verify_v2_proposal_value(head, proposal_id, value)
12425}
12426
12427fn verify_v2_proposal_value(
12428 head: &V2VerifiedHead,
12429 proposal_id: &str,
12430 value: Value,
12431) -> LinkResult<VerifiedV2Proposal> {
12432 if value.get("v").and_then(Value::as_u64) != Some(2) {
12433 return Err(invalid_feed("proposal response has an invalid version"));
12434 }
12435 let proposal = value
12436 .get("proposal")
12437 .and_then(Value::as_object)
12438 .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
12439 if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
12440 return Err(invalid_feed("proposal response changed its id"));
12441 }
12442 let payload_hash = proposal
12443 .get("payload_sha256")
12444 .and_then(Value::as_str)
12445 .filter(|hash| is_sha256(hash))
12446 .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
12447 let clear_hash = proposal
12448 .get("clear_sha256")
12449 .and_then(Value::as_str)
12450 .filter(|hash| is_sha256(hash))
12451 .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
12452 let submission_hash = proposal
12453 .get("submission_claim_sha256")
12454 .and_then(Value::as_str)
12455 .filter(|hash| is_sha256(hash))
12456 .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
12457 let submission = STANDARD
12458 .decode(
12459 proposal
12460 .get("submission_claim_base64")
12461 .and_then(Value::as_str)
12462 .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
12463 )
12464 .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
12465 let submission_value: Value = serde_json::from_slice(&submission)
12466 .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
12467 if crate::linkmd_v2::canonical_bytes(&submission_value)
12468 .map_err(|error| invalid_feed(error.to_string()))?
12469 != submission
12470 || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
12471 .map_err(|error| invalid_feed(error.to_string()))?
12472 != submission_hash
12473 {
12474 return Err(invalid_feed(
12475 "proposal submission claim is not canonical or addressed",
12476 ));
12477 }
12478 let envelope = submission_value
12479 .as_object()
12480 .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
12481 let claim = envelope
12482 .get("claim")
12483 .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
12484 let claim_object = claim
12485 .as_object()
12486 .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
12487 let actor_root = claim_object
12488 .get("actor_root")
12489 .and_then(Value::as_object)
12490 .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
12491 let public_key = envelope
12492 .get("public_key")
12493 .and_then(Value::as_str)
12494 .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
12495 let fingerprint = envelope
12496 .get("fingerprint")
12497 .and_then(Value::as_str)
12498 .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
12499 let signature = envelope
12500 .get("sig")
12501 .and_then(Value::as_str)
12502 .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
12503 let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
12504 .map_err(|error| invalid_feed(error.to_string()))?;
12505 let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
12506 let signer = format!("{fingerprint}:{public_key}");
12507 let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
12508 let grants = actor_root.get("grants").and_then(Value::as_array);
12509 let grants_are_canonical = grants.is_some_and(|items| {
12510 let mut prior: Option<&str> = None;
12511 items.iter().all(|item| {
12512 let Some(grant) = item.as_str() else {
12513 return false;
12514 };
12515 if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
12516 return false;
12517 }
12518 prior = Some(grant);
12519 true
12520 })
12521 });
12522 let optional_actor_field = |name: &str| {
12523 actor_root.get(name).is_some_and(|value| {
12524 value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
12525 })
12526 };
12527 let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
12528 if claim_object.get("v").and_then(Value::as_u64) != Some(2)
12529 || format!("{:x}", Sha256::digest(&der)) != fingerprint
12530 || head
12531 .trust
12532 .hub_signer
12533 .as_ref()
12534 .is_some_and(|known| known != &signer)
12535 || !matches!(
12536 actor_class,
12537 Some(
12538 "user"
12539 | "owned_agent"
12540 | "foreign_key"
12541 | "curation"
12542 | "inbox"
12543 | "restore"
12544 | "migration"
12545 | "operator_recovery"
12546 )
12547 )
12548 || actor_root
12549 .get("principal")
12550 .and_then(Value::as_str)
12551 .is_none_or(|value| value.is_empty())
12552 || actor_root
12553 .get("credential")
12554 .and_then(Value::as_str)
12555 .is_none_or(|value| value.is_empty())
12556 || !optional_actor_field("organization")
12557 || !optional_actor_field("role")
12558 || !grants_are_canonical
12559 || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
12560 || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12561 || !claim_object
12562 .get("mutation_id")
12563 .and_then(Value::as_str)
12564 .is_some_and(|value| {
12565 !value.is_empty()
12566 && value.len() <= 128
12567 && value.chars().enumerate().all(|(index, char)| {
12568 char.is_ascii_alphanumeric()
12569 || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
12570 })
12571 })
12572 || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
12573 || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
12574 || !claim_object
12575 .get("control_revision")
12576 .and_then(Value::as_str)
12577 .is_some_and(is_sha256)
12578 || submitted_at.is_none_or(|value| {
12579 chrono::DateTime::parse_from_rfc3339(value).is_err()
12580 || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
12581 })
12582 || !proposal
12583 .get("state")
12584 .and_then(Value::as_str)
12585 .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
12586 || proposal
12587 .get("expires_at")
12588 .and_then(Value::as_str)
12589 .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
12590 || proposal
12591 .get("proposer")
12592 .and_then(Value::as_object)
12593 .and_then(|value| value.get("class"))
12594 .and_then(Value::as_str)
12595 != actor_class
12596 {
12597 return Err(invalid_feed(
12598 "proposal submission claim does not bind the verified proposal",
12599 ));
12600 }
12601 let changes_b64 = proposal
12602 .get("changes_base64")
12603 .and_then(Value::as_str)
12604 .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
12605 let changes_bytes = STANDARD
12606 .decode(changes_b64)
12607 .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
12608 let changes: Value = serde_json::from_slice(&changes_bytes)
12609 .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
12610 if crate::linkmd_v2::canonical_bytes(&changes)
12611 .map_err(|error| invalid_feed(error.to_string()))?
12612 != changes_bytes
12613 || changes.get("v").and_then(Value::as_u64) != Some(2)
12614 || !changes.get("operations").is_some_and(Value::is_array)
12615 {
12616 return Err(invalid_feed("proposal changeset is not canonical v2"));
12617 }
12618 let blob_values = proposal
12619 .get("blobs")
12620 .and_then(Value::as_array)
12621 .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
12622 let mut blobs = Vec::with_capacity(blob_values.len());
12623 let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
12624 let mut prior_hash: Option<String> = None;
12625 for item in blob_values {
12626 let hash = item
12627 .get("sha256")
12628 .and_then(Value::as_str)
12629 .filter(|hash| is_sha256(hash))
12630 .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
12631 let bytes = item
12632 .get("bytes")
12633 .and_then(Value::as_u64)
12634 .filter(|bytes| *bytes <= MAX_STORE_BYTES)
12635 .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
12636 if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
12637 return Err(invalid_feed(
12638 "proposal blob declarations are not unique and sorted",
12639 ));
12640 }
12641 prior_hash = Some(hash.to_string());
12642 let endpoint = item
12643 .get("endpoint")
12644 .and_then(Value::as_str)
12645 .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
12646 let expected_endpoint = format!(
12647 "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
12648 head.brain_id
12649 );
12650 if endpoint != expected_endpoint {
12651 return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
12652 }
12653 descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
12654 blobs.push((hash.to_string(), bytes, endpoint.to_string()));
12655 }
12656 let descriptor = json!({
12657 "base": proposal.get("base").cloned().unwrap_or(Value::Null),
12658 "blobs": descriptor_blobs,
12659 "changes_base64": changes_b64,
12660 "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
12661 "v": 2,
12662 });
12663 let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
12664 .map_err(|error| invalid_feed(error.to_string()))?;
12665 if content_sha256(&descriptor_bytes) != clear_hash {
12666 return Err(invalid_feed(
12667 "proposal clear payload differs from its signed submission claim",
12668 ));
12669 }
12670 Ok(VerifiedV2Proposal {
12671 value,
12672 changes,
12673 blobs,
12674 })
12675}
12676
12677pub fn proposal_list(
12678 cfg: &HubConfig,
12679 brain: &str,
12680 state: &str,
12681 after: Option<&str>,
12682 limit: usize,
12683) -> LinkResult<Value> {
12684 require_safe_ref(brain)?;
12685 if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
12686 return Err(invalid_feed("proposal state is invalid"));
12687 }
12688 if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
12689 return Err(invalid_feed("proposal cursor is invalid"));
12690 }
12691 let head = v2_verified_head(cfg, brain)?
12692 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12693 let path = format!(
12694 "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
12695 head.brain_id,
12696 limit.clamp(1, 100),
12697 after.map_or_else(String::new, |value| format!("&after={value}"))
12698 );
12699 ensure_ok(
12700 request_capped(
12701 cfg,
12702 "GET",
12703 &path,
12704 None,
12705 Auth::Required,
12706 MAX_FEED_RESPONSE_BYTES,
12707 )?,
12708 "v2 proposal list",
12709 )
12710}
12711
12712pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
12713 require_safe_ref(brain)?;
12714 let head = v2_verified_head(cfg, brain)?
12715 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12716 Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
12717}
12718
12719pub fn proposal_reject(
12720 cfg: &HubConfig,
12721 brain: &str,
12722 proposal_id: &str,
12723 mutation_id: &str,
12724 reason: &str,
12725) -> LinkResult<Value> {
12726 require_safe_ref(brain)?;
12727 require_proposal_id(proposal_id)?;
12728 let head = v2_verified_head(cfg, brain)?
12729 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12730 let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
12731 let body = json!({
12732 "mutation_id": mutation_id,
12733 "control_revision": head.control_revision,
12734 "reason": reason,
12735 });
12736 let path = format!(
12737 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
12738 head.brain_id
12739 );
12740 ensure_ok(
12741 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
12742 "v2 proposal rejection",
12743 )
12744}
12745
12746pub fn proposal_accept_exact(
12747 cfg: &HubConfig,
12748 brain: &str,
12749 proposal_id: &str,
12750 mutation_id: &str,
12751 reason: &str,
12752) -> LinkResult<Value> {
12753 require_safe_ref(brain)?;
12754 require_proposal_id(proposal_id)?;
12755 let head = v2_verified_head(cfg, brain)?
12756 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12757 let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
12758 let operations = proposal
12759 .changes
12760 .get("operations")
12761 .and_then(Value::as_array)
12762 .cloned()
12763 .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
12764 if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
12765 return Err(invalid_feed("proposal operation count is invalid"));
12766 }
12767 let mut downloaded = std::collections::BTreeMap::new();
12768 for (hash, bytes, endpoint) in &proposal.blobs {
12769 let body = ensure_raw_ok(
12770 request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
12771 "v2 proposal blob",
12772 )?;
12773 if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
12774 return Err(invalid_feed("proposal blob does not match its declaration"));
12775 }
12776 downloaded.insert(hash.clone(), body);
12777 }
12778 let remote = files_for_v2_view(
12779 &head,
12780 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
12781 );
12782 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
12783 let mut expected_candidate = remote.clone();
12784 let mut expected_candidate_assets = remote_assets;
12785 for operation in &operations {
12786 let op = operation
12787 .get("op")
12788 .and_then(Value::as_str)
12789 .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
12790 match op {
12791 "put" | "restore" => {
12792 let path = operation
12793 .get("path")
12794 .and_then(Value::as_str)
12795 .ok_or_else(|| invalid_feed("proposal write has no path"))?;
12796 crate::linkmd_v2::normalize_path(path)
12797 .map_err(|error| invalid_feed(error.to_string()))?;
12798 let hash = operation
12799 .get("blob")
12800 .and_then(Value::as_str)
12801 .filter(|hash| is_sha256(hash))
12802 .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
12803 let bytes = operation
12804 .get("bytes")
12805 .and_then(Value::as_u64)
12806 .ok_or_else(|| invalid_feed("proposal write has no size"))?;
12807 expected_candidate.insert(
12808 path.to_string(),
12809 V2BaselineFile {
12810 sha256: hash.to_string(),
12811 bytes,
12812 proof: None,
12813 },
12814 );
12815 }
12816 "delete" | "withdraw_from_hosting" => {
12817 let path = operation
12818 .get("path")
12819 .and_then(Value::as_str)
12820 .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
12821 crate::linkmd_v2::normalize_path(path)
12822 .map_err(|error| invalid_feed(error.to_string()))?;
12823 expected_candidate.remove(path);
12824 }
12825 "rename" => {
12826 let from = operation
12827 .get("from")
12828 .and_then(Value::as_str)
12829 .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
12830 let to = operation
12831 .get("to")
12832 .and_then(Value::as_str)
12833 .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
12834 crate::linkmd_v2::normalize_path(from)
12835 .and_then(|_| crate::linkmd_v2::normalize_path(to))
12836 .map_err(|error| invalid_feed(error.to_string()))?;
12837 let hash = operation
12838 .get("blob")
12839 .and_then(Value::as_str)
12840 .filter(|hash| is_sha256(hash))
12841 .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
12842 let bytes = operation
12843 .get("bytes")
12844 .and_then(Value::as_u64)
12845 .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
12846 expected_candidate.remove(from);
12847 expected_candidate.insert(
12848 to.to_string(),
12849 V2BaselineFile {
12850 sha256: hash.to_string(),
12851 bytes,
12852 proof: None,
12853 },
12854 );
12855 }
12856 "asset_delete" => {
12857 let path = operation
12858 .get("path")
12859 .and_then(Value::as_str)
12860 .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
12861 expected_candidate_assets.remove(path);
12862 }
12863 "asset_withdraw" => {
12864 let path = operation
12865 .get("path")
12866 .and_then(Value::as_str)
12867 .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
12868 if !expected_candidate_assets.contains_key(path) {
12869 return Err(invalid_feed("proposal withdraws an unknown asset"));
12870 }
12871 let Some(asset) = operation.get("asset").and_then(Value::as_object) else {
12872 let prior = expected_candidate_assets
12876 .get_mut(path)
12877 .expect("presence checked above");
12878 prior.disposition = "withheld".to_string();
12879 prior.leaf_hash.clear();
12880 continue;
12881 };
12882 let blob_sha256 = asset
12883 .get("blob_sha256")
12884 .and_then(Value::as_str)
12885 .filter(|hash| is_sha256(hash))
12886 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12887 let bytes = asset
12888 .get("bytes")
12889 .and_then(Value::as_u64)
12890 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12891 let media_type = asset
12892 .get("media_type")
12893 .and_then(Value::as_str)
12894 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12895 let wrappers = asset
12896 .get("wrappers")
12897 .and_then(Value::as_array)
12898 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12899 .iter()
12900 .map(|wrapper| {
12901 wrapper
12902 .as_str()
12903 .map(str::to_string)
12904 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12905 })
12906 .collect::<LinkResult<Vec<_>>>()?;
12907 let required = asset
12908 .get("required")
12909 .and_then(Value::as_bool)
12910 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12911 if asset.get("disposition").and_then(Value::as_str) != Some("withheld") {
12912 return Err(invalid_feed("proposal asset withdrawal is not withheld"));
12913 }
12914 expected_candidate_assets.insert(
12915 path.to_string(),
12916 V2BaselineAsset {
12917 blob_sha256: blob_sha256.to_string(),
12918 bytes,
12919 media_type: media_type.to_string(),
12920 wrappers,
12921 required,
12922 disposition: "withheld".to_string(),
12923 leaf_hash: String::new(),
12924 },
12925 );
12926 }
12927 "asset_put" | "asset_resume" => {
12928 let path = operation
12929 .get("path")
12930 .and_then(Value::as_str)
12931 .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
12932 let asset = operation
12933 .get("asset")
12934 .and_then(Value::as_object)
12935 .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
12936 let blob_sha256 = asset
12937 .get("blob_sha256")
12938 .and_then(Value::as_str)
12939 .filter(|hash| is_sha256(hash))
12940 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12941 let bytes = asset
12942 .get("bytes")
12943 .and_then(Value::as_u64)
12944 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12945 let media_type = asset
12946 .get("media_type")
12947 .and_then(Value::as_str)
12948 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12949 let wrappers = asset
12950 .get("wrappers")
12951 .and_then(Value::as_array)
12952 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12953 .iter()
12954 .map(|wrapper| {
12955 wrapper
12956 .as_str()
12957 .map(str::to_string)
12958 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12959 })
12960 .collect::<LinkResult<Vec<_>>>()?;
12961 let required = asset
12962 .get("required")
12963 .and_then(Value::as_bool)
12964 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12965 let disposition = asset
12966 .get("disposition")
12967 .and_then(Value::as_str)
12968 .filter(|value| matches!(*value, "hosted" | "withheld"))
12969 .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
12970 expected_candidate_assets.insert(
12971 path.to_string(),
12972 V2BaselineAsset {
12973 blob_sha256: blob_sha256.to_string(),
12974 bytes,
12975 media_type: media_type.to_string(),
12976 wrappers,
12977 required,
12978 disposition: disposition.to_string(),
12979 leaf_hash: String::new(),
12980 },
12981 );
12982 }
12983 _ => return Err(invalid_feed("proposal operation kind is unsupported")),
12984 }
12985 }
12986 let base = head.pointer.as_ref().map(|pointer| {
12987 json!({
12988 "seq": pointer.seq,
12989 "commit_hash": pointer.commit_hash,
12990 "content_root": pointer.content_root,
12991 "asset_root": pointer.asset_root,
12992 })
12993 });
12994 let mut body = json!({
12995 "mutation_id": mutation_id,
12996 "base": base,
12997 "rebase": "strict",
12998 "reason": reason,
12999 "operations": operations,
13000 "blobs": downloaded
13001 .iter()
13002 .map(|(sha256, bytes)| json!({
13003 "sha256": sha256,
13004 "bytes": bytes.len(),
13005 "content_base64": STANDARD.encode(bytes),
13006 }))
13007 .collect::<Vec<_>>(),
13008 "proposal_id": proposal_id,
13009 "proposal_mode": "exact",
13010 });
13011 let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
13012 total
13013 .checked_add(bytes.len())
13014 .ok_or_else(|| LinkError::PushTooLarge {
13015 detail: "proposal changed-byte total overflow".to_string(),
13016 })
13017 })?;
13018 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
13019 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
13020 for operation in &operations {
13021 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
13022 return Err(invalid_feed("proposal upload operation has no kind"));
13023 };
13024 let hash = match kind {
13025 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
13026 "asset_put" | "asset_resume" => operation
13027 .get("asset")
13028 .and_then(|asset| asset.get("blob_sha256"))
13029 .and_then(Value::as_str),
13030 _ => None,
13031 };
13032 let Some(hash) = hash else { continue };
13033 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
13034 if kind == "rename" {
13035 for field in ["from", "to"] {
13036 coordinates.insert(
13037 operation
13038 .get(field)
13039 .and_then(Value::as_str)
13040 .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
13041 .to_string(),
13042 );
13043 }
13044 } else {
13045 let path = operation
13046 .get("path")
13047 .and_then(Value::as_str)
13048 .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
13049 coordinates.insert(if kind.starts_with("asset_") {
13050 format!("assets/{path}")
13051 } else {
13052 path.to_string()
13053 });
13054 }
13055 }
13056 let declarations = downloaded
13057 .iter()
13058 .map(|(sha256, bytes)| {
13059 json!({
13060 "sha256": sha256,
13061 "bytes": bytes.len(),
13062 "coordinates": coordinates_by_hash
13063 .get(sha256)
13064 .into_iter()
13065 .flatten()
13066 .collect::<Vec<_>>(),
13067 })
13068 })
13069 .collect::<Vec<_>>();
13070 let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
13071 for batch in batch_upload_declarations(declarations) {
13072 let reserved = reserve_upload_window(
13073 cfg,
13074 &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
13075 &json!({ "blobs": batch }),
13076 "prepare proposal blob transport",
13077 )?;
13078 let reserved_items = reserved
13079 .get("uploads")
13080 .and_then(Value::as_array)
13081 .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
13082 items.extend(reserved_items.iter().cloned());
13083 }
13084 if items.len() != downloaded.len() {
13085 return Err(invalid_feed("proposal upload reservation changed the set"));
13086 }
13087 let mut references = Vec::with_capacity(items.len());
13088 for item in items {
13089 let hash = item
13090 .get("sha256")
13091 .and_then(Value::as_str)
13092 .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
13093 let bytes = downloaded
13094 .get(hash)
13095 .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
13096 let reservation_id = item
13097 .get("reservation_id")
13098 .and_then(Value::as_str)
13099 .filter(|id| crate::ulid::is_ulid(id))
13100 .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
13101 let expected_coordinates = coordinates_by_hash
13102 .get(hash)
13103 .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
13104 let returned_coordinates = item
13105 .get("coordinates")
13106 .and_then(Value::as_array)
13107 .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
13108 if returned_coordinates.len() != expected_coordinates.len()
13109 || returned_coordinates
13110 .iter()
13111 .zip(expected_coordinates)
13112 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
13113 {
13114 return Err(invalid_feed(
13115 "proposal upload reservation changed its coordinates",
13116 ));
13117 }
13118 match item.get("status").and_then(Value::as_str) {
13119 Some("upload") => put_presigned(
13120 cfg,
13121 item.get("url")
13122 .and_then(Value::as_str)
13123 .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
13124 item.get("headers").unwrap_or(&Value::Null),
13125 bytes,
13126 )?,
13127 Some("already_present") => {}
13128 _ => return Err(invalid_feed("proposal upload status is invalid")),
13129 }
13130 references.push(json!({
13131 "sha256": hash,
13132 "bytes": bytes.len(),
13133 "reservation_id": reservation_id,
13134 }));
13135 }
13136 body["blobs"] = Value::Array(references);
13137 }
13138 stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
13142 let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
13143 let mut result = ensure_ok(
13144 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
13145 "exact proposal acceptance",
13146 )?;
13147 let mut candidate_hub_signer = None;
13148 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
13149 let request_id = result
13150 .get("request_id")
13151 .and_then(Value::as_str)
13152 .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
13153 .to_string();
13154 let challenge = result
13155 .get("signing_challenge")
13156 .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
13157 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
13158 cfg,
13159 &head,
13160 &expected_candidate,
13161 &expected_candidate_assets,
13162 mutation_id,
13163 &v2_signed_request_view(&body, &operations),
13164 challenge,
13165 )?;
13166 body["signing_challenge_id"] = Value::String(challenge_id);
13167 body["signature_base64url"] = Value::String(signature);
13168 candidate_hub_signer = Some(actor_signer);
13169 result = ensure_ok(
13170 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
13171 "signed exact proposal acceptance",
13172 )?;
13173 }
13174 let refreshed = v2_verified_head(cfg, brain)?
13175 .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
13176 if candidate_hub_signer
13177 .as_ref()
13178 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
13179 || refreshed
13180 .pointer
13181 .as_ref()
13182 .map(|pointer| pointer.commit_hash.as_str())
13183 != result.get("commit_hash").and_then(Value::as_str)
13184 || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
13185 || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
13186 {
13187 return Err(LinkError::RemoteAdvancedDuringSync);
13188 }
13189 accept_v2_head(cfg, &refreshed)?;
13190 Ok(result)
13191}
13192
13193pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
13204 require_valid_handle(handle)?;
13205 if body.len() as u64 > MAX_PROPOSE_BYTES {
13206 return Err(LinkError::ProposeTooLarge {
13207 bytes: body.len() as u64,
13208 });
13209 }
13210 let payload = json!({ "app": app, "body": body });
13211 let (path, auth) = if crate::ulid::is_ulid(handle) {
13216 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
13217 } else {
13218 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
13219 };
13220 ensure_ok(
13221 request(cfg, "POST", &path, Some(&payload), auth)?,
13222 "propose",
13223 )
13224}
13225
13226#[derive(Debug, serde::Serialize)]
13232pub struct Head {
13233 pub brain: String,
13235 pub seq: u64,
13237 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
13239 pub updated_at: Option<String>,
13240 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
13242 pub feed_hash: Option<String>,
13243 pub verified: bool,
13246}
13247
13248struct BoundedVecVisitor<T, const MAX: usize> {
13249 label: &'static str,
13250 marker: std::marker::PhantomData<T>,
13251}
13252
13253impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
13254where
13255 T: Deserialize<'de>,
13256{
13257 type Value = Vec<T>;
13258
13259 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13260 write!(formatter, "at most {MAX} {}", self.label)
13261 }
13262
13263 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
13264 where
13265 A: serde::de::SeqAccess<'de>,
13266 {
13267 if sequence.size_hint().is_some_and(|size| size > MAX) {
13268 return Err(serde::de::Error::custom(format!(
13269 "{} exceeds the {MAX}-item limit",
13270 self.label
13271 )));
13272 }
13273 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
13274 while let Some(value) = sequence.next_element()? {
13275 if values.len() == MAX {
13276 return Err(serde::de::Error::custom(format!(
13277 "{} exceeds the {MAX}-item limit",
13278 self.label
13279 )));
13280 }
13281 values.push(value);
13282 }
13283 Ok(values)
13284 }
13285}
13286
13287fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
13288 deserializer: D,
13289 label: &'static str,
13290) -> Result<Vec<T>, D::Error>
13291where
13292 D: serde::Deserializer<'de>,
13293 T: Deserialize<'de>,
13294{
13295 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
13296 label,
13297 marker: std::marker::PhantomData,
13298 })
13299}
13300
13301fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
13302where
13303 D: serde::Deserializer<'de>,
13304{
13305 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
13306}
13307
13308fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
13309where
13310 D: serde::Deserializer<'de>,
13311{
13312 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
13313}
13314
13315fn deserialize_previous_identities<'de, D>(
13316 deserializer: D,
13317) -> Result<Vec<PreviousIdentity>, D::Error>
13318where
13319 D: serde::Deserializer<'de>,
13320{
13321 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
13322 deserializer,
13323 "previous identities",
13324 )
13325}
13326
13327fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
13328where
13329 D: serde::Deserializer<'de>,
13330{
13331 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
13332 deserializer,
13333 "rotation statements",
13334 )
13335}
13336
13337fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
13338where
13339 D: serde::Deserializer<'de>,
13340{
13341 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
13342}
13343
13344#[derive(Debug, Clone, Deserialize, Serialize)]
13345struct FeedFile {
13346 path: String,
13347 sha256: String,
13348 bytes: u64,
13349}
13350
13351#[cfg(test)]
13352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13353enum V1DisclosureError {
13354 DuplicateFile,
13355 DuplicateRemoved,
13356 PushManifestMismatch,
13357 EditMissingChange,
13358 EditFalseFile,
13359 RemovedMismatch,
13360}
13361
13362#[cfg(test)]
13366fn verify_v1_manifest_disclosure(
13367 kind: &str,
13368 previous: &[FeedFile],
13369 resulting: &[FeedFile],
13370 files: &[FeedFile],
13371 removed: &[String],
13372) -> Result<(), V1DisclosureError> {
13373 fn as_map(
13374 files: &[FeedFile],
13375 ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
13376 let mut result = std::collections::BTreeMap::new();
13377 for file in files {
13378 if result
13379 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
13380 .is_some()
13381 {
13382 return Err(V1DisclosureError::DuplicateFile);
13383 }
13384 }
13385 Ok(result)
13386 }
13387 let previous = as_map(previous)?;
13388 let resulting = as_map(resulting)?;
13389 let disclosed = as_map(files)?;
13390 let removed_set: std::collections::BTreeSet<&str> =
13391 removed.iter().map(String::as_str).collect();
13392 if removed_set.len() != removed.len() {
13393 return Err(V1DisclosureError::DuplicateRemoved);
13394 }
13395 let expected_removed: std::collections::BTreeSet<&str> = previous
13396 .keys()
13397 .copied()
13398 .filter(|path| !resulting.contains_key(path))
13399 .collect();
13400 if removed_set != expected_removed {
13401 return Err(V1DisclosureError::RemovedMismatch);
13402 }
13403 if kind == "push" {
13404 return if disclosed == resulting {
13405 Ok(())
13406 } else {
13407 Err(V1DisclosureError::PushManifestMismatch)
13408 };
13409 }
13410 if kind != "edit" {
13411 return Err(V1DisclosureError::EditFalseFile);
13412 }
13413 if disclosed
13414 .iter()
13415 .any(|(path, value)| resulting.get(path) != Some(value))
13416 {
13417 return Err(V1DisclosureError::EditFalseFile);
13418 }
13419 for (path, value) in &resulting {
13420 if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
13421 return Err(V1DisclosureError::EditMissingChange);
13422 }
13423 }
13424 Ok(())
13425}
13426
13427#[derive(Debug, Clone, Deserialize, Serialize)]
13428struct FeedEntry {
13429 v: u8,
13430 seq: u64,
13431 ts: String,
13432 brain: String,
13433 public_key: String,
13434 kind: String,
13435 op: String,
13436 pack_sha256: String,
13437 #[serde(deserialize_with = "deserialize_feed_files")]
13438 files: Vec<FeedFile>,
13439 #[serde(deserialize_with = "deserialize_removed_paths")]
13440 removed: Vec<String>,
13441 prev_entry_hash: Option<String>,
13442 sig: String,
13443}
13444
13445#[derive(Serialize)]
13446struct UnsignedFeedEntry<'a> {
13447 v: u8,
13448 seq: u64,
13449 ts: &'a str,
13450 brain: &'a str,
13451 public_key: &'a str,
13452 kind: &'a str,
13453 op: &'a str,
13454 pack_sha256: &'a str,
13455 files: &'a [FeedFile],
13456 removed: &'a [String],
13457 prev_entry_hash: &'a Option<String>,
13458}
13459
13460#[derive(Debug, Clone, Deserialize, Serialize)]
13461struct FeedItem {
13462 hash: String,
13463 entry: FeedEntry,
13464}
13465
13466#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
13467struct FeedIdentity {
13468 fingerprint: String,
13469 #[serde(rename = "publicKeySpki")]
13470 public_key_spki: String,
13471 #[serde(default, deserialize_with = "deserialize_previous_identities")]
13475 previous: Vec<PreviousIdentity>,
13476 #[serde(default, deserialize_with = "deserialize_rotations")]
13479 rotations: Vec<String>,
13480}
13481
13482#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
13483struct PreviousIdentity {
13484 fingerprint: String,
13485 #[serde(rename = "publicKeySpki")]
13486 public_key_spki: String,
13487}
13488
13489#[derive(Debug, Deserialize)]
13490struct FeedResponse {
13491 #[serde(rename = "headSeq")]
13492 head_seq: u64,
13493 #[serde(rename = "feedHash")]
13494 feed_hash: Option<String>,
13495 identity: Option<FeedIdentity>,
13496 #[serde(deserialize_with = "deserialize_feed_items")]
13497 entries: Vec<FeedItem>,
13498 #[serde(rename = "scopeLimited")]
13499 scope_limited: bool,
13500}
13501
13502#[derive(Debug, Deserialize, Serialize)]
13503#[serde(deny_unknown_fields)]
13504struct RotationStatement {
13505 v: u8,
13506 op: String,
13507 brain: String,
13508 public_key: String,
13509 new_brain: String,
13510 new_public_key: String,
13511 prior_head_seq: u64,
13512 prior_feed_hash: Option<String>,
13513 ts: String,
13514 sig: String,
13515}
13516
13517#[derive(Debug, Clone, Deserialize, Serialize)]
13518struct TrustState {
13519 v: u8,
13520 origin: String,
13521 #[serde(default)]
13525 requested: String,
13526 brain: String,
13528 #[serde(default, skip_serializing_if = "Option::is_none")]
13531 home: Option<String>,
13532 anchor: String,
13533 current: String,
13534 #[serde(rename = "headSeq")]
13535 head_seq: u64,
13536 #[serde(rename = "feedHash")]
13537 feed_hash: Option<String>,
13538 #[serde(default)]
13542 rotations: Vec<String>,
13543 #[serde(default, skip_serializing_if = "Option::is_none")]
13546 hub_signer: Option<String>,
13547 #[serde(default, skip_serializing_if = "Option::is_none")]
13550 protocol_profile: Option<String>,
13551}
13552
13553fn accepted_as_v2(state: &TrustState) -> bool {
13554 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
13555}
13556
13557fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
13558 let directory = open_trust_dir(cfg)?;
13559 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
13560 return Ok(true);
13561 }
13562 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
13563 return Ok(false);
13564 };
13565 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
13566}
13567
13568#[derive(Debug, Clone, Deserialize, Serialize)]
13569struct AliasBinding {
13570 v: u8,
13571 origin: String,
13572 requested: String,
13573 brain: String,
13574 #[serde(default, skip_serializing_if = "Option::is_none")]
13575 home: Option<String>,
13576}
13577
13578struct VerifiedRemote {
13579 head: Head,
13580 identity: Option<FeedIdentity>,
13581 head_entry: Option<FeedItem>,
13582 entries: Vec<FeedItem>,
13584 anchor: Option<String>,
13585}
13586
13587fn invalid_feed(message: impl Into<String>) -> LinkError {
13588 LinkError::InvalidFeed {
13589 message: message.into(),
13590 }
13591}
13592
13593fn is_sha256(value: &str) -> bool {
13594 value.len() == 64
13595 && value
13596 .bytes()
13597 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
13598}
13599
13600fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
13601 let der = URL_SAFE_NO_PAD
13602 .decode(public_key_spki)
13603 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
13604 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
13605 return Err(invalid_feed(
13606 "identity public key is not a valid Ed25519 SPKI",
13607 ));
13608 }
13609 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
13610}
13611
13612fn verify_identity_chain(
13616 identity: &FeedIdentity,
13617 pinned: Option<&TrustState>,
13618) -> LinkResult<String> {
13619 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
13620 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
13621 {
13622 return Err(invalid_feed(
13623 "identity rotation history exceeds the client cap",
13624 ));
13625 }
13626 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
13627 return Err(invalid_feed(
13628 "current identity fingerprint does not match its public key",
13629 ));
13630 }
13631 for previous in &identity.previous {
13632 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
13633 return Err(invalid_feed(
13634 "previous identity fingerprint does not match its public key",
13635 ));
13636 }
13637 }
13638 if identity.rotations.len() != identity.previous.len() {
13639 return Err(invalid_feed(
13640 "identity history is missing an old-key-signed rotation statement",
13641 ));
13642 }
13643
13644 let mut chain: Vec<(&str, &str)> = identity
13648 .previous
13649 .iter()
13650 .rev()
13651 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
13652 .collect();
13653 chain.push((&identity.fingerprint, &identity.public_key_spki));
13654
13655 for (index, raw) in identity.rotations.iter().enumerate() {
13656 let statement: RotationStatement = serde_json::from_str(raw)
13657 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
13658 let (old_fingerprint, old_spki) = chain[index];
13659 let (new_fingerprint, new_spki) = chain[index + 1];
13660 if statement.v != 1
13661 || statement.op != "rotate"
13662 || statement.brain != format!("ed25519:{old_fingerprint}")
13663 || statement.public_key != old_spki
13664 || statement.new_brain != format!("ed25519:{new_fingerprint}")
13665 || statement.new_public_key != new_spki
13666 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
13667 || (statement.prior_head_seq > 0
13668 && statement
13669 .prior_feed_hash
13670 .as_deref()
13671 .is_none_or(|hash| !is_sha256(hash)))
13672 {
13673 return Err(invalid_feed(
13674 "rotation statement does not connect adjacent identities",
13675 ));
13676 }
13677 let unsigned = serde_json::to_string(&UnsignedRotation {
13678 v: statement.v,
13679 op: &statement.op,
13680 brain: &statement.brain,
13681 public_key: &statement.public_key,
13682 new_brain: &statement.new_brain,
13683 new_public_key: &statement.new_public_key,
13684 prior_head_seq: statement.prior_head_seq,
13685 prior_feed_hash: statement.prior_feed_hash.as_deref(),
13686 ts: statement.ts.clone(),
13687 })
13688 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
13689 let exact = format!(
13690 "{},\"sig\":\"{}\"}}",
13691 &unsigned[..unsigned.len() - 1],
13692 statement.sig
13693 );
13694 if exact != *raw {
13695 return Err(invalid_feed(
13696 "rotation statement is not in normative serialization",
13697 ));
13698 }
13699 let der = URL_SAFE_NO_PAD
13700 .decode(old_spki)
13701 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
13702 let signature = URL_SAFE_NO_PAD
13703 .decode(&statement.sig)
13704 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
13705 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
13706 .verify(unsigned.as_bytes(), &signature)
13707 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
13708 if index > 0 {
13709 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
13710 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
13711 if statement.prior_head_seq < prior.prior_head_seq {
13712 return Err(invalid_feed("rotation feed boundaries move backward"));
13713 }
13714 }
13715 }
13716
13717 let anchor = format!("ed25519:{}", chain[0].0);
13718 let current = format!("ed25519:{}", identity.fingerprint);
13719 if let Some(pin) = pinned {
13720 if pin.anchor != anchor {
13721 return Err(invalid_feed(
13722 "served identity chain does not descend from the pinned anchor",
13723 ));
13724 }
13725 if !chain
13726 .iter()
13727 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
13728 {
13729 return Err(invalid_feed(
13730 "served identity chain forked away from the last pinned identity",
13731 ));
13732 }
13733 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
13734 return Err(invalid_feed("served identity discarded its rotation chain"));
13735 }
13736 if pin.v >= 2
13737 && (identity.rotations.len() < pin.rotations.len()
13738 || identity.rotations[..pin.rotations.len()] != pin.rotations)
13739 {
13740 return Err(invalid_feed(
13741 "served identity rewrote the locally accepted rotation history",
13742 ));
13743 }
13744 }
13745 Ok(anchor)
13746}
13747
13748fn verify_rotation_feed_boundaries(
13749 identity: &FeedIdentity,
13750 pinned: Option<&TrustState>,
13751 observed: &[FeedItem],
13752 advertised_seq: u64,
13753) -> LinkResult<()> {
13754 let mut chain: Vec<String> = identity
13755 .previous
13756 .iter()
13757 .rev()
13758 .map(|previous| format!("ed25519:{}", previous.fingerprint))
13759 .collect();
13760 chain.push(format!("ed25519:{}", identity.fingerprint));
13761 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
13762
13763 for (index, raw) in identity.rotations.iter().enumerate() {
13764 let rotation: RotationStatement = serde_json::from_str(raw)
13765 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13766 if rotation.prior_head_seq > advertised_seq {
13767 return Err(invalid_feed(
13768 "rotation claims a feed boundary beyond the advertised head",
13769 ));
13770 }
13771 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
13772 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
13773 return Err(invalid_feed(
13774 "newly disclosed rotation predates the local feed checkpoint",
13775 ));
13776 }
13777 }
13778 let actual = if rotation.prior_head_seq == 0 {
13779 None
13780 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
13781 pinned.and_then(|pin| pin.feed_hash.as_deref())
13782 } else {
13783 observed
13784 .iter()
13785 .find(|item| item.entry.seq == rotation.prior_head_seq)
13786 .map(|item| item.hash.as_str())
13787 };
13788 if let Some(actual) = actual {
13789 if rotation.prior_feed_hash.as_deref() != Some(actual) {
13790 return Err(invalid_feed(
13791 "rotation statement does not commit the verified feed boundary",
13792 ));
13793 }
13794 } else if rotation.prior_head_seq == 0 {
13795 } else if pinned.is_some_and(|pin| {
13798 pinned_index.is_some_and(|pin_index| index >= pin_index)
13799 || rotation.prior_head_seq >= pin.head_seq
13800 }) {
13801 return Err(invalid_feed(
13802 "rotation feed boundary was not present in the verified chain",
13803 ));
13804 }
13805 }
13806 Ok(())
13807}
13808
13809fn reject_retired_signer_after_checkpoint(
13814 identity: &FeedIdentity,
13815 pinned: Option<&TrustState>,
13816 item: &FeedItem,
13817) -> LinkResult<()> {
13818 let Some(pin) = pinned else {
13819 return Ok(());
13820 };
13821 if item.entry.seq <= pin.head_seq {
13822 return Ok(());
13823 }
13824 let mut chain: Vec<String> = identity
13825 .previous
13826 .iter()
13827 .rev()
13828 .map(|previous| format!("ed25519:{}", previous.fingerprint))
13829 .collect();
13830 chain.push(format!("ed25519:{}", identity.fingerprint));
13831 let pinned_index = chain
13832 .iter()
13833 .position(|key| key == &pin.current)
13834 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
13835 let signer_index = chain
13836 .iter()
13837 .position(|key| key == &item.entry.brain)
13838 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
13839 if signer_index < pinned_index {
13840 return Err(invalid_feed(
13841 "a retired identity attempted to sign after the local checkpoint",
13842 ));
13843 }
13844 Ok(())
13845}
13846
13847fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
13848 let origin = normalized_origin(&cfg.hub)?;
13849 let key = format!(
13850 "{:x}",
13851 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
13852 );
13853 Ok(format!("{key}.json"))
13854}
13855
13856fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
13857 let origin = normalized_origin(&cfg.hub)?;
13858 let key = format!(
13859 "{:x}",
13860 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
13861 );
13862 Ok(format!("alias-{key}.json"))
13863}
13864
13865#[cfg(any(unix, windows))]
13866struct TrustLock {
13867 _file: std::fs::File,
13868}
13869
13870#[cfg(unix)]
13871fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13872 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13873
13874 let lock_string = format!(".{state_name}.lock");
13875 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
13876 let fd = unsafe {
13877 libc::openat(
13878 directory.as_raw_fd(),
13879 lock_name.as_ptr(),
13880 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13881 0o600,
13882 )
13883 };
13884 if fd < 0 {
13885 return Err(std::io::Error::last_os_error().into());
13886 }
13887 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13888 if !file.metadata()?.is_file() {
13889 return Err(LinkError::UnsafePath { path: lock_string });
13890 }
13891 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
13892 return Err(std::io::Error::last_os_error().into());
13893 }
13894 Ok(TrustLock { _file: file })
13895}
13896
13897#[cfg(windows)]
13898fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13899 let lock_name = format!(".{state_name}.lock");
13900 let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
13901 Ok(TrustLock { _file: file })
13902}
13903
13904#[cfg(any(unix, windows))]
13905fn lock_trust_many(
13906 cfg: &HubConfig,
13907 directory: &std::fs::File,
13908 refs: &[&str],
13909) -> LinkResult<Vec<TrustLock>> {
13910 let mut names = refs
13911 .iter()
13912 .map(|reference| trust_file_name(cfg, reference))
13913 .collect::<LinkResult<Vec<_>>>()?;
13914 names.sort();
13915 names.dedup();
13916 names
13917 .iter()
13918 .map(|name| lock_trust_name(directory, name))
13919 .collect()
13920}
13921
13922#[cfg(not(any(unix, windows)))]
13923fn lock_trust_many(
13924 _cfg: &HubConfig,
13925 _directory: &TrustDirectory,
13926 _refs: &[&str],
13927) -> LinkResult<Vec<()>> {
13928 Err(LinkError::UnsupportedPlatform {
13929 operation: "verified link.md state",
13930 })
13931}
13932
13933#[cfg(any(unix, windows))]
13934type TrustDirectory = std::fs::File;
13935
13936#[cfg(not(any(unix, windows)))]
13937struct TrustDirectory;
13938
13939#[cfg(unix)]
13940fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13941 use std::os::fd::AsRawFd as _;
13942
13943 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
13944 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
13945 return Err(std::io::Error::last_os_error().into());
13946 }
13947 directory.sync_all()?;
13948 Ok(directory)
13949}
13950
13951#[cfg(windows)]
13952fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13953 let marker = cfg.state_dir.join("trust").join(".directory");
13954 crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
13955 Ok(crate::fsx::open_directory_nofollow(
13956 marker.parent().expect("trust marker has a parent"),
13957 )?)
13958}
13959
13960#[cfg(not(any(unix, windows)))]
13961fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13962 Err(LinkError::UnsupportedPlatform {
13963 operation: "verified link.md state",
13964 })
13965}
13966
13967#[cfg(unix)]
13968fn load_trust_in(
13969 cfg: &HubConfig,
13970 directory: &TrustDirectory,
13971 requested: &str,
13972) -> LinkResult<Option<TrustState>> {
13973 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13974
13975 let name_string = trust_file_name(cfg, requested)?;
13976 let name = c_name(name_string.as_bytes(), &name_string)?;
13977 let fd = unsafe {
13978 libc::openat(
13979 directory.as_raw_fd(),
13980 name.as_ptr(),
13981 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13982 )
13983 };
13984 if fd < 0 {
13985 let error = std::io::Error::last_os_error();
13986 if error.kind() == std::io::ErrorKind::NotFound {
13987 return Ok(None);
13988 }
13989 return Err(LinkError::UnsafePath { path: name_string });
13990 }
13991 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13992 if !file.metadata()?.is_file() {
13993 return Err(LinkError::UnsafePath { path: name_string });
13994 }
13995 let mut bytes = Vec::new();
13996 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
13997 if bytes.len() > 1024 * 1024 {
13998 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
13999 }
14000 let mut state: TrustState = serde_json::from_slice(&bytes)
14001 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
14002 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
14003 return Err(invalid_feed(
14004 "local identity/feed checkpoint does not match this hub and brain",
14005 ));
14006 }
14007 if state.v == 1 {
14008 if state.brain != requested {
14012 return Err(invalid_feed(
14013 "legacy checkpoint is not bound to the requested brain id",
14014 ));
14015 }
14016 state.requested = requested.to_string();
14017 } else if state.requested != requested {
14018 return Err(invalid_feed(
14019 "local identity/feed checkpoint is bound to a different requested ref",
14020 ));
14021 }
14022 Ok(Some(state))
14023}
14024
14025#[cfg(windows)]
14026fn load_trust_in(
14027 cfg: &HubConfig,
14028 directory: &TrustDirectory,
14029 requested: &str,
14030) -> LinkResult<Option<TrustState>> {
14031 let name = trust_file_name(cfg, requested)?;
14032 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
14033 let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
14034 Ok(bytes) => bytes,
14035 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
14036 Err(_) => return Err(LinkError::UnsafePath { path: name }),
14037 };
14038 let mut state: TrustState = serde_json::from_slice(&bytes)
14039 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
14040 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
14041 return Err(invalid_feed(
14042 "local identity/feed checkpoint does not match this hub and brain",
14043 ));
14044 }
14045 if state.v == 1 {
14046 if state.brain != requested {
14047 return Err(invalid_feed(
14048 "legacy checkpoint is not bound to the requested brain id",
14049 ));
14050 }
14051 state.requested = requested.to_string();
14052 } else if state.requested != requested {
14053 return Err(invalid_feed(
14054 "local identity/feed checkpoint is bound to a different requested ref",
14055 ));
14056 }
14057 Ok(Some(state))
14058}
14059
14060#[cfg(not(any(unix, windows)))]
14061fn load_trust_in(
14062 _cfg: &HubConfig,
14063 _directory: &TrustDirectory,
14064 _brain: &str,
14065) -> LinkResult<Option<TrustState>> {
14066 Err(LinkError::UnsupportedPlatform {
14067 operation: "verified link.md state",
14068 })
14069}
14070
14071#[cfg(all(test, any(unix, windows)))]
14072fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
14073 let directory = open_trust_dir(cfg)?;
14074 load_trust_in(cfg, &directory, requested)
14075}
14076
14077#[cfg(unix)]
14078fn save_trust_in(
14079 cfg: &HubConfig,
14080 directory: &TrustDirectory,
14081 state: &TrustState,
14082) -> LinkResult<()> {
14083 use std::os::fd::{AsRawFd as _, FromRawFd as _};
14084
14085 let name_string = trust_file_name(cfg, &state.requested)?;
14086 let name = c_name(name_string.as_bytes(), &name_string)?;
14087 let mut bytes = serde_json::to_vec(state)
14088 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
14089 bytes.push(b'\n');
14090
14091 let nonce = std::time::SystemTime::now()
14092 .duration_since(std::time::UNIX_EPOCH)
14093 .unwrap_or_default()
14094 .as_nanos();
14095 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
14096 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
14097 let fd = unsafe {
14098 libc::openat(
14099 directory.as_raw_fd(),
14100 temp.as_ptr(),
14101 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14102 0o600,
14103 )
14104 };
14105 if fd < 0 {
14106 return Err(std::io::Error::last_os_error().into());
14107 }
14108 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
14109 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
14110 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14111 return Err(error.into());
14112 }
14113 drop(file);
14114 if unsafe {
14115 libc::renameat(
14116 directory.as_raw_fd(),
14117 temp.as_ptr(),
14118 directory.as_raw_fd(),
14119 name.as_ptr(),
14120 )
14121 } != 0
14122 {
14123 let error = std::io::Error::last_os_error();
14124 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14125 return Err(error.into());
14126 }
14127 directory.sync_all()?;
14128 Ok(())
14129}
14130
14131#[cfg(windows)]
14132fn save_trust_in(
14133 cfg: &HubConfig,
14134 directory: &TrustDirectory,
14135 state: &TrustState,
14136) -> LinkResult<()> {
14137 let name = trust_file_name(cfg, &state.requested)?;
14138 let mut bytes = serde_json::to_vec(state)
14139 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
14140 bytes.push(b'\n');
14141 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
14142 Ok(())
14143}
14144
14145#[cfg(not(any(unix, windows)))]
14146fn save_trust_in(
14147 _cfg: &HubConfig,
14148 _directory: &TrustDirectory,
14149 _state: &TrustState,
14150) -> LinkResult<()> {
14151 Err(LinkError::UnsupportedPlatform {
14152 operation: "verified link.md state",
14153 })
14154}
14155
14156#[cfg(unix)]
14157fn load_alias_in(
14158 cfg: &HubConfig,
14159 directory: &TrustDirectory,
14160 requested: &str,
14161) -> LinkResult<Option<AliasBinding>> {
14162 use std::os::fd::{AsRawFd as _, FromRawFd as _};
14163
14164 let name_string = alias_file_name(cfg, requested)?;
14165 let name = c_name(name_string.as_bytes(), &name_string)?;
14166 let fd = unsafe {
14167 libc::openat(
14168 directory.as_raw_fd(),
14169 name.as_ptr(),
14170 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14171 )
14172 };
14173 if fd < 0 {
14174 let error = std::io::Error::last_os_error();
14175 if error.kind() == std::io::ErrorKind::NotFound {
14176 return Ok(None);
14177 }
14178 return Err(LinkError::UnsafePath { path: name_string });
14179 }
14180 let file = unsafe { std::fs::File::from_raw_fd(fd) };
14181 if !file.metadata()?.is_file() {
14182 return Err(LinkError::UnsafePath { path: name_string });
14183 }
14184 let mut bytes = Vec::new();
14185 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
14186 if bytes.len() > 64 * 1024 {
14187 return Err(invalid_feed("local alias binding is oversized"));
14188 }
14189 let alias: AliasBinding = serde_json::from_slice(&bytes)
14190 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
14191 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
14192 {
14193 return Err(invalid_feed(
14194 "local alias binding does not match this hub and requested ref",
14195 ));
14196 }
14197 Ok(Some(alias))
14198}
14199
14200#[cfg(windows)]
14201fn load_alias_in(
14202 cfg: &HubConfig,
14203 directory: &TrustDirectory,
14204 requested: &str,
14205) -> LinkResult<Option<AliasBinding>> {
14206 let name = alias_file_name(cfg, requested)?;
14207 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
14208 let bytes = match reader.read(Path::new(&name), 64 * 1024) {
14209 Ok(bytes) => bytes,
14210 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
14211 Err(_) => return Err(LinkError::UnsafePath { path: name }),
14212 };
14213 let alias: AliasBinding = serde_json::from_slice(&bytes)
14214 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
14215 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
14216 {
14217 return Err(invalid_feed(
14218 "local alias binding does not match this hub and requested ref",
14219 ));
14220 }
14221 Ok(Some(alias))
14222}
14223
14224#[cfg(not(any(unix, windows)))]
14225fn load_alias_in(
14226 _cfg: &HubConfig,
14227 _directory: &TrustDirectory,
14228 _requested: &str,
14229) -> LinkResult<Option<AliasBinding>> {
14230 Err(LinkError::UnsupportedPlatform {
14231 operation: "verified link.md state",
14232 })
14233}
14234
14235#[cfg(unix)]
14236fn save_alias_in(
14237 cfg: &HubConfig,
14238 directory: &TrustDirectory,
14239 alias: &AliasBinding,
14240) -> LinkResult<()> {
14241 use std::os::fd::{AsRawFd as _, FromRawFd as _};
14242
14243 let name_string = alias_file_name(cfg, &alias.requested)?;
14244 let name = c_name(name_string.as_bytes(), &name_string)?;
14245 let mut bytes = serde_json::to_vec(alias)
14246 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
14247 bytes.push(b'\n');
14248 let nonce = std::time::SystemTime::now()
14249 .duration_since(std::time::UNIX_EPOCH)
14250 .unwrap_or_default()
14251 .as_nanos();
14252 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
14253 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
14254 let fd = unsafe {
14255 libc::openat(
14256 directory.as_raw_fd(),
14257 temp.as_ptr(),
14258 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14259 0o600,
14260 )
14261 };
14262 if fd < 0 {
14263 return Err(std::io::Error::last_os_error().into());
14264 }
14265 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
14266 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
14267 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14268 return Err(error.into());
14269 }
14270 drop(file);
14271 if unsafe {
14272 libc::renameat(
14273 directory.as_raw_fd(),
14274 temp.as_ptr(),
14275 directory.as_raw_fd(),
14276 name.as_ptr(),
14277 )
14278 } != 0
14279 {
14280 let error = std::io::Error::last_os_error();
14281 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14282 return Err(error.into());
14283 }
14284 directory.sync_all()?;
14285 Ok(())
14286}
14287
14288#[cfg(windows)]
14289fn save_alias_in(
14290 cfg: &HubConfig,
14291 directory: &TrustDirectory,
14292 alias: &AliasBinding,
14293) -> LinkResult<()> {
14294 let name = alias_file_name(cfg, &alias.requested)?;
14295 let mut bytes = serde_json::to_vec(alias)
14296 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
14297 bytes.push(b'\n');
14298 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
14299 Ok(())
14300}
14301
14302#[cfg(not(any(unix, windows)))]
14303fn save_alias_in(
14304 _cfg: &HubConfig,
14305 _directory: &TrustDirectory,
14306 _alias: &AliasBinding,
14307) -> LinkResult<()> {
14308 Err(LinkError::UnsupportedPlatform {
14309 operation: "verified link.md state",
14310 })
14311}
14312
14313fn load_canonical_pin(
14318 cfg: &HubConfig,
14319 directory: &TrustDirectory,
14320 requested: &str,
14321 resolved_brain: &str,
14322) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
14323 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
14324 if requested == resolved_brain {
14325 return Ok((canonical, None));
14326 }
14327
14328 let mut alias = load_alias_in(cfg, directory, requested)?;
14329 if let Some(binding) = &alias {
14330 if binding.brain != resolved_brain {
14331 return Err(LinkError::AliasRebindRequired {
14332 alias: requested.to_string(),
14333 from: binding.brain.clone(),
14334 to: resolved_brain.to_string(),
14335 });
14336 }
14337 return Ok((canonical, alias));
14338 }
14339
14340 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
14344 if legacy.brain != resolved_brain {
14345 return Err(invalid_feed(
14346 "legacy alias checkpoint names a different canonical brain",
14347 ));
14348 }
14349 if let Some(existing) = &canonical {
14350 if existing.brain != legacy.brain
14351 || existing.anchor != legacy.anchor
14352 || existing.current != legacy.current
14353 || existing.head_seq != legacy.head_seq
14354 || existing.feed_hash != legacy.feed_hash
14355 || existing.rotations != legacy.rotations
14356 {
14357 return Err(invalid_feed(
14358 "legacy alias checkpoint conflicts with the canonical checkpoint",
14359 ));
14360 }
14361 } else {
14362 let mut promoted = legacy.clone();
14363 promoted.requested = resolved_brain.to_string();
14364 promoted.home = None;
14365 save_trust_in(cfg, directory, &promoted)?;
14366 canonical = Some(promoted);
14367 }
14368 alias = Some(AliasBinding {
14369 v: 1,
14370 origin: normalized_origin(&cfg.hub)?,
14371 requested: requested.to_string(),
14372 brain: resolved_brain.to_string(),
14373 home: legacy.home,
14374 });
14375 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
14376 }
14377 Ok((canonical, alias))
14378}
14379
14380pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
14385 require_hardened_filesystem("verified alias rebind")?;
14386 require_safe_ref(alias)?;
14387 require_safe_ref(from)?;
14388 require_safe_ref(to)?;
14389 if crate::ulid::is_ulid(alias)
14390 || !crate::ulid::is_ulid(from)
14391 || !crate::ulid::is_ulid(to)
14392 || from == to
14393 {
14394 return Err(LinkError::InvalidPack {
14395 message:
14396 "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
14397 .to_string(),
14398 });
14399 }
14400
14401 let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
14402 message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
14403 })?;
14404 accept_v2_head(cfg, &verified)?;
14405
14406 let alias_response = ensure_ok(
14407 request(
14408 cfg,
14409 "GET",
14410 &format!("/api/hub/brains/{alias}/v2/head"),
14411 None,
14412 Auth::Required,
14413 )?,
14414 "resolve alias for explicit rebind",
14415 )?;
14416 let resolved: V2HeadResponse = serde_json::from_value(alias_response)
14417 .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
14418 if resolved.v != 2 || resolved.brain_id != to {
14419 return Err(LinkError::RemoteAdvancedDuringSync);
14420 }
14421
14422 let directory = open_trust_dir(cfg)?;
14423 let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
14424 let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
14425 message: "the requested alias has no existing local binding to replace".to_string(),
14426 })?;
14427 if binding.brain != from {
14428 return Err(LinkError::AliasRebindRequired {
14429 alias: alias.to_string(),
14430 from: binding.brain,
14431 to: to.to_string(),
14432 });
14433 }
14434 save_alias_in(
14435 cfg,
14436 &directory,
14437 &AliasBinding {
14438 v: 1,
14439 origin: normalized_origin(&cfg.hub)?,
14440 requested: alias.to_string(),
14441 brain: to.to_string(),
14442 home: binding.home,
14443 },
14444 )?;
14445 Ok(json!({
14446 "v": 2,
14447 "alias": alias,
14448 "from": from,
14449 "to": to,
14450 "outcome": "alias_rebound",
14451 }))
14452}
14453
14454fn save_canonical_pin_and_alias(
14455 cfg: &HubConfig,
14456 directory: &TrustDirectory,
14457 requested: &str,
14458 resolved_brain: &str,
14459 mut state: TrustState,
14460 existing_alias: Option<&AliasBinding>,
14461) -> LinkResult<()> {
14462 state.requested = resolved_brain.to_string();
14463 state.brain = resolved_brain.to_string();
14464 state.home = None;
14465 save_trust_in(cfg, directory, &state)?;
14466 if requested != resolved_brain {
14467 save_alias_in(
14468 cfg,
14469 directory,
14470 &AliasBinding {
14471 v: 1,
14472 origin: normalized_origin(&cfg.hub)?,
14473 requested: requested.to_string(),
14474 brain: resolved_brain.to_string(),
14475 home: existing_alias.and_then(|alias| alias.home.clone()),
14476 },
14477 )?;
14478 }
14479 Ok(())
14480}
14481
14482fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
14483 const ED25519_SPKI_PREFIX: &[u8] = &[
14484 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
14485 ];
14486 let entry = &item.entry;
14487 let public_der = URL_SAFE_NO_PAD
14488 .decode(&entry.public_key)
14489 .map_err(|_| invalid_feed("public key is not base64url"))?;
14490 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
14491 || !public_der.starts_with(ED25519_SPKI_PREFIX)
14492 {
14493 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
14494 }
14495 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
14496 if entry.brain != format!("ed25519:{fingerprint}") {
14497 return Err(invalid_feed(
14498 "brain fingerprint does not match its public key",
14499 ));
14500 }
14501 let _ = verify_identity_chain(identity, None)?;
14503 let mut chain: Vec<(&str, &str)> = identity
14504 .previous
14505 .iter()
14506 .rev()
14507 .map(|previous| {
14508 (
14509 previous.fingerprint.as_str(),
14510 previous.public_key_spki.as_str(),
14511 )
14512 })
14513 .collect();
14514 chain.push((&identity.fingerprint, &identity.public_key_spki));
14515 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
14516 *known_fingerprint == fingerprint && *spki == entry.public_key
14517 });
14518 let Some(signer_index) = signer_index else {
14519 return Err(invalid_feed(
14520 "entry signer is not this brain's identity (current or rotated-from)",
14521 ));
14522 };
14523 let lower_boundary = if signer_index == 0 {
14524 None
14525 } else {
14526 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
14527 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
14528 Some(prior.prior_head_seq)
14529 };
14530 let upper_boundary = if signer_index == identity.rotations.len() {
14531 None
14532 } else {
14533 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
14534 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
14535 Some(next.prior_head_seq)
14536 };
14537 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
14538 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
14539 {
14540 return Err(invalid_feed(
14541 "entry signer is outside its authenticated rotation epoch",
14542 ));
14543 }
14544 let unsigned = UnsignedFeedEntry {
14545 v: entry.v,
14546 seq: entry.seq,
14547 ts: &entry.ts,
14548 brain: &entry.brain,
14549 public_key: &entry.public_key,
14550 kind: &entry.kind,
14551 op: &entry.op,
14552 pack_sha256: &entry.pack_sha256,
14553 files: &entry.files,
14554 removed: &entry.removed,
14555 prev_entry_hash: &entry.prev_entry_hash,
14556 };
14557 let message =
14558 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
14559 let signature = URL_SAFE_NO_PAD
14560 .decode(&entry.sig)
14561 .map_err(|_| invalid_feed("signature is not base64url"))?;
14562 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
14563 .verify(&message, &signature)
14564 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
14565
14566 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
14567 exact.push(b'\n');
14568 let actual_hash = format!("{:x}", Sha256::digest(&exact));
14569 if actual_hash != item.hash {
14570 return Err(invalid_feed("entry SHA-256 does not match"));
14571 }
14572 Ok(())
14573}
14574
14575#[derive(Serialize)]
14581struct UnsignedRotation<'a> {
14582 v: u8,
14583 op: &'a str,
14584 brain: &'a str,
14585 public_key: &'a str,
14586 new_brain: &'a str,
14587 new_public_key: &'a str,
14588 prior_head_seq: u64,
14589 prior_feed_hash: Option<&'a str>,
14590 ts: String,
14591}
14592
14593#[derive(Debug, Deserialize, Serialize)]
14598#[serde(deny_unknown_fields)]
14599struct RotationJournal {
14600 v: u8,
14601 origin: String,
14602 brain: String,
14603 old_brain: String,
14604 new_brain: String,
14605 prior_head_seq: u64,
14606 prior_feed_hash: Option<String>,
14607 statement: String,
14608}
14609
14610fn rotation_journal_path(key_path: &Path) -> PathBuf {
14611 let mut path = key_path.as_os_str().to_os_string();
14612 path.push(".rotation.json");
14613 PathBuf::from(path)
14614}
14615
14616fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
14617 #[cfg(unix)]
14618 let file = {
14619 use std::os::fd::{AsRawFd as _, FromRawFd as _};
14620 use std::os::unix::ffi::OsStrExt as _;
14621 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
14622 .map_err(|error| {
14623 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
14624 })?;
14625 let leaf_name = path
14626 .file_name()
14627 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
14628 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
14629 let fd = unsafe {
14630 libc::openat(
14631 parent.as_raw_fd(),
14632 leaf.as_ptr(),
14633 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14634 )
14635 };
14636 if fd < 0 {
14637 return Err(bad_agent_key(
14638 "the rotation journal must be an existing regular file without symlink ancestors",
14639 ));
14640 }
14641 unsafe { std::fs::File::from_raw_fd(fd) }
14642 };
14643 #[cfg(not(unix))]
14644 let file = std::fs::File::open(path)
14645 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
14646 let metadata = file
14647 .metadata()
14648 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
14649 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
14650 return Err(bad_agent_key(
14651 "the rotation journal must be a bounded regular file",
14652 ));
14653 }
14654 #[cfg(unix)]
14655 {
14656 use std::os::unix::fs::PermissionsExt as _;
14657 if metadata.permissions().mode() & 0o077 != 0 {
14658 return Err(bad_agent_key(
14659 "the rotation journal is accessible to group/other; set mode 0600",
14660 ));
14661 }
14662 }
14663 serde_json::from_reader(file)
14664 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
14665}
14666
14667fn remove_rotation_journal(path: &Path) {
14668 #[cfg(unix)]
14669 {
14670 use std::os::fd::AsRawFd as _;
14671 use std::os::unix::ffi::OsStrExt as _;
14672 let Ok(parent) =
14673 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
14674 else {
14675 return;
14676 };
14677 let Some(leaf_name) = path.file_name() else {
14678 return;
14679 };
14680 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
14681 return;
14682 };
14683 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
14684 let _ = parent.sync_all();
14685 }
14686 }
14687 #[cfg(not(unix))]
14688 {
14689 let _ = std::fs::remove_file(path);
14690 }
14691}
14692
14693fn validate_rotation_journal(
14694 journal: &RotationJournal,
14695 cfg: &HubConfig,
14696 canonical_brain: &str,
14697 old_key: &AgentSigningKey,
14698 new_key: &AgentSigningKey,
14699 head: &Head,
14700) -> LinkResult<()> {
14701 if journal.v != 1
14702 || journal.origin != normalized_origin(&cfg.hub)?
14703 || journal.brain != canonical_brain
14704 || journal.old_brain != old_key.multikey
14705 || journal.new_brain != new_key.multikey
14706 || journal.prior_head_seq != head.seq
14707 || journal.prior_feed_hash != head.feed_hash
14708 {
14709 return Err(invalid_feed(
14710 "rotation journal does not match the verified key and feed boundary",
14711 ));
14712 }
14713 let statement: RotationStatement = serde_json::from_str(&journal.statement)
14714 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
14715 if statement.prior_head_seq != journal.prior_head_seq
14716 || statement.prior_feed_hash != journal.prior_feed_hash
14717 || statement.brain != old_key.multikey
14718 || statement.public_key != old_key.public_key_spki
14719 || statement.new_brain != new_key.multikey
14720 || statement.new_public_key != new_key.public_key_spki
14721 {
14722 return Err(invalid_feed(
14723 "rotation journal statement does not match its durable intent",
14724 ));
14725 }
14726 let identity = FeedIdentity {
14727 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
14728 public_key_spki: new_key.public_key_spki.clone(),
14729 previous: vec![PreviousIdentity {
14730 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
14731 public_key_spki: old_key.public_key_spki.clone(),
14732 }],
14733 rotations: vec![journal.statement.clone()],
14734 };
14735 verify_identity_chain(&identity, None)?;
14736 Ok(())
14737}
14738
14739#[derive(Debug, Serialize)]
14741pub struct RotationReport {
14742 pub brain: String,
14744 pub multikey: String,
14746 #[serde(rename = "keyFile")]
14748 pub key_file: String,
14749 pub previous: Vec<String>,
14751}
14752
14753pub fn rotate_brain_key(
14759 cfg: &HubConfig,
14760 brain: &str,
14761 old_key: &AgentSigningKey,
14762 out: &Path,
14763) -> LinkResult<RotationReport> {
14764 require_hardened_filesystem("key rotation")?;
14765 require_safe_ref(brain)?;
14766 let new_key = if out.exists() {
14770 load_signing_key(out)?
14771 } else {
14772 let rng = ring::rand::SystemRandom::new();
14773 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
14774 .map_err(|_| bad_agent_key("key generation failed"))?;
14775 let pair = agent_keypair(pkcs8.as_ref())?;
14776 let (public_key_spki, multikey) = public_identity_for(&pair);
14777 write_secret_new(
14778 out,
14779 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
14780 )?;
14781 AgentSigningKey {
14782 pkcs8: pkcs8.as_ref().to_vec(),
14783 multikey,
14784 public_key_spki,
14785 }
14786 };
14787 let new_spki = new_key.public_key_spki.clone();
14788 let new_multikey = new_key.multikey.clone();
14789 let journal_path = rotation_journal_path(out);
14790 let before_v2 = v2_verified_head(cfg, brain)?;
14791 let (canonical_brain, served_identity, observed_head, v2_profile) =
14792 if let Some(head) = before_v2 {
14793 let observed = Head {
14794 brain: head.brain_id.clone(),
14795 seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14796 updated_at: head
14797 .pointer
14798 .as_ref()
14799 .map(|pointer| pointer.signed_at.clone()),
14800 feed_hash: head
14801 .pointer
14802 .as_ref()
14803 .map(|pointer| pointer.feed_hash.clone()),
14804 verified: true,
14805 };
14806 let identity = v2_identity(&head.identity);
14807 let canonical = head.brain_id.clone();
14808 accept_v2_head(cfg, &head)?;
14809 (canonical, identity, observed, true)
14810 } else {
14811 let remote = verified_remote_head(cfg, brain, false)?;
14812 let identity = remote
14813 .identity
14814 .clone()
14815 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
14816 (remote.head.brain.clone(), identity, remote.head, false)
14817 };
14818 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
14819 let already_rotated = served_multikey == new_multikey;
14820 if already_rotated && !journal_path.exists() {
14825 remove_rotation_journal(&journal_path);
14826 return Ok(RotationReport {
14827 brain: brain.to_string(),
14828 multikey: new_multikey,
14829 key_file: out.display().to_string(),
14830 previous: served_identity
14831 .previous
14832 .iter()
14833 .map(|identity| format!("ed25519:{}", identity.fingerprint))
14834 .collect(),
14835 });
14836 }
14837 if !already_rotated && served_multikey != old_key.multikey {
14838 return Err(invalid_feed(
14839 "the supplied old key is not the brain's verified current identity",
14840 ));
14841 }
14842
14843 let journal = if journal_path.exists() {
14844 read_rotation_journal(&journal_path)?
14845 } else {
14846 let ts = crate::now()
14847 .with_timezone(&chrono::Utc)
14848 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
14849 .to_string();
14850 let unsigned = serde_json::to_string(&UnsignedRotation {
14851 v: 1,
14852 op: "rotate",
14853 brain: &old_key.multikey,
14854 public_key: &old_key.public_key_spki,
14855 new_brain: &new_multikey,
14856 new_public_key: &new_spki,
14857 prior_head_seq: observed_head.seq,
14858 prior_feed_hash: observed_head.feed_hash.as_deref(),
14859 ts,
14860 })
14861 .expect("serialize rotation");
14862 let old_pair = agent_keypair(&old_key.pkcs8)?;
14863 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14864 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
14865 let journal = RotationJournal {
14866 v: 1,
14867 origin: normalized_origin(&cfg.hub)?,
14868 brain: canonical_brain.clone(),
14869 old_brain: old_key.multikey.clone(),
14870 new_brain: new_multikey.clone(),
14871 prior_head_seq: observed_head.seq,
14872 prior_feed_hash: observed_head.feed_hash.clone(),
14873 statement,
14874 };
14875 let mut exact = serde_json::to_vec(&journal)
14876 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
14877 exact.push(b'\n');
14878 if write_secret_new(&journal_path, &exact).is_err() {
14879 read_rotation_journal(&journal_path)?
14882 } else {
14883 journal
14884 }
14885 };
14886 validate_rotation_journal(
14887 &journal,
14888 cfg,
14889 &canonical_brain,
14890 old_key,
14891 &new_key,
14892 &observed_head,
14893 )?;
14894
14895 let body = json!({ "statement": journal.statement });
14896 let path = format!("/api/hub/brains/{brain}/rotate");
14897 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
14898 let attempted_failure = match attempted {
14899 Ok(response) if (200..300).contains(&response.status) => None,
14900 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
14901 Err(error) => Some(error),
14902 };
14903
14904 let identity = if v2_profile {
14908 match v2_verified_head(cfg, brain) {
14909 Ok(Some(after)) => {
14910 let identity = v2_identity(&after.identity);
14911 accept_v2_head(cfg, &after)?;
14912 identity
14913 }
14914 Ok(None) => {
14915 return Err(attempted_failure.unwrap_or_else(|| {
14916 invalid_feed("rotated v2 brain no longer serves a v2 head")
14917 }));
14918 }
14919 Err(error) => return Err(attempted_failure.unwrap_or(error)),
14920 }
14921 } else {
14922 match verified_remote_head(cfg, brain, false) {
14923 Ok(after) => after
14924 .identity
14925 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
14926 Err(error) => return Err(attempted_failure.unwrap_or(error)),
14927 }
14928 };
14929 if format!("ed25519:{}", identity.fingerprint) != new_multikey
14930 || identity.public_key_spki != new_spki
14931 {
14932 return Err(attempted_failure.unwrap_or_else(|| {
14933 invalid_feed("hub acknowledged rotation without committing the verified new identity")
14934 }));
14935 }
14936 if v2_profile {
14937 if let Some(error) = attempted_failure {
14938 return Err(error);
14943 }
14944 }
14945 let previous = identity
14946 .previous
14947 .iter()
14948 .map(|prior| format!("ed25519:{}", prior.fingerprint))
14949 .collect();
14950 remove_rotation_journal(&journal_path);
14951
14952 Ok(RotationReport {
14953 brain: brain.to_string(),
14954 multikey: new_multikey,
14955 key_file: out.display().to_string(),
14956 previous,
14957 })
14958}
14959
14960#[derive(Debug, Serialize)]
14966pub struct MirrorReport {
14967 pub brain: String,
14969 #[serde(rename = "headSeq")]
14971 pub head_seq: u64,
14972 #[serde(rename = "feedHash")]
14974 pub feed_hash: Option<String>,
14975 pub entries: u64,
14977 pub pinned: String,
14979 pub files: usize,
14981}
14982
14983pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
14985
14986#[derive(Debug)]
14988pub struct VerifiedMirrorMaterial {
14989 pub brain: String,
14990 pub head_seq: u64,
14991 pub feed_hash: Option<String>,
14992 pub identity: serde_json::Value,
14993 pub entries: Vec<(u64, String, String)>,
14995 pub pack_sha256: Option<String>,
14996}
14997
14998#[derive(Deserialize)]
14999#[serde(deny_unknown_fields)]
15000struct StoredMirrorHead {
15001 brain: String,
15002 #[serde(rename = "headSeq")]
15003 head_seq: u64,
15004 #[serde(rename = "feedHash")]
15005 feed_hash: Option<String>,
15006}
15007
15008pub fn verify_mirror_material(
15011 head_bytes: &[u8],
15012 identity_bytes: &[u8],
15013 feed_bytes: &[Vec<u8>],
15014 snapshot_pack: Option<&[u8]>,
15015 expected_anchor: &str,
15016) -> LinkResult<VerifiedMirrorMaterial> {
15017 let snapshot_hash = snapshot_pack
15018 .filter(|pack| !pack.is_empty())
15019 .map(content_sha256);
15020 verify_mirror_material_with_pack_hash(
15021 head_bytes,
15022 identity_bytes,
15023 feed_bytes,
15024 snapshot_hash.as_deref(),
15025 expected_anchor,
15026 )
15027}
15028
15029pub fn verify_mirror_material_with_pack_hash(
15033 head_bytes: &[u8],
15034 identity_bytes: &[u8],
15035 feed_bytes: &[Vec<u8>],
15036 snapshot_pack_sha256: Option<&str>,
15037 expected_anchor: &str,
15038) -> LinkResult<VerifiedMirrorMaterial> {
15039 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
15040 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
15041 require_safe_ref(&head.brain)?;
15042 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
15043 return Err(invalid_feed(
15044 "stored mirror feed count does not match its bounded head sequence",
15045 ));
15046 }
15047 let aggregate = feed_bytes
15048 .iter()
15049 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
15050 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
15051 if aggregate > MAX_FEED_REPLAY_BYTES {
15052 return Err(invalid_feed(
15053 "stored mirror feed metadata exceeds the aggregate limit",
15054 ));
15055 }
15056 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
15057 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
15058 let anchor = verify_identity_chain(&identity, None)?;
15059 if anchor != expected_anchor {
15060 return Err(invalid_feed(
15061 "stored mirror identity does not descend from the explicitly trusted anchor",
15062 ));
15063 }
15064
15065 let mut entries = Vec::with_capacity(feed_bytes.len());
15066 let mut items = Vec::with_capacity(feed_bytes.len());
15067 let mut previous_hash = None;
15068 let mut pack_sha256 = None;
15069 for (index, bytes) in feed_bytes.iter().enumerate() {
15070 let exact = bytes
15071 .strip_suffix(b"\n")
15072 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
15073 if exact.ends_with(b"\n") {
15074 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
15075 }
15076 let entry: FeedEntry = serde_json::from_slice(exact)
15077 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
15078 let expected_seq = index as u64 + 1;
15079 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
15080 return Err(invalid_feed(
15081 "stored mirror feed is not contiguous and hash-chained",
15082 ));
15083 }
15084 let canonical = serde_json::to_vec(&entry)
15085 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
15086 if canonical != exact {
15087 return Err(invalid_feed(
15088 "stored feed entry is not in normative serialization",
15089 ));
15090 }
15091 let hash = content_sha256(bytes);
15092 let item = FeedItem {
15093 hash: hash.clone(),
15094 entry,
15095 };
15096 verify_feed_item(&item, &identity)?;
15097 previous_hash = Some(hash.clone());
15098 if expected_seq == head.head_seq {
15099 pack_sha256 = Some(item.entry.pack_sha256.clone());
15100 }
15101 entries.push((
15102 expected_seq,
15103 std::str::from_utf8(exact)
15104 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
15105 .to_string(),
15106 hash,
15107 ));
15108 items.push(item);
15109 }
15110 if previous_hash != head.feed_hash {
15111 return Err(invalid_feed(
15112 "stored mirror feed does not converge on its advertised head",
15113 ));
15114 }
15115 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
15116 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
15117 (0, None, None) => {}
15118 (_, Some(actual), Some(expected)) if actual == expected => {}
15119 _ => {
15120 return Err(LinkError::InvalidPack {
15121 message: "stored snapshot pack does not match the signed head digest".to_string(),
15122 });
15123 }
15124 }
15125 let identity_value = serde_json::to_value(&identity)
15126 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
15127 Ok(VerifiedMirrorMaterial {
15128 brain: head.brain,
15129 head_seq: head.head_seq,
15130 feed_hash: head.feed_hash,
15131 identity: identity_value,
15132 entries,
15133 pack_sha256,
15134 })
15135}
15136
15137pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
15140 format!(
15141 "{:x}",
15142 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
15143 )
15144}
15145
15146pub fn content_sha256(bytes: &[u8]) -> String {
15149 format!("{:x}", Sha256::digest(bytes))
15150}
15151
15152pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
15154 let mut digest = Sha256::new();
15155 let mut buffer = [0u8; 64 * 1024];
15156 loop {
15157 let read = reader.read(&mut buffer)?;
15158 if read == 0 {
15159 break;
15160 }
15161 digest.update(&buffer[..read]);
15162 }
15163 Ok(format!("{:x}", digest.finalize()))
15164}
15165
15166#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
15174pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
15175 require_hardened_filesystem("mirror")?;
15176 require_safe_ref(brain)?;
15177 #[cfg(windows)]
15178 {
15179 let _ = (cfg, dest);
15180 return Err(LinkError::UnsupportedPlatform {
15181 operation: "atomic whole-mirror replacement on Windows",
15182 });
15183 }
15184 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
15185 let name = dest
15186 .file_name()
15187 .and_then(|name| name.to_str())
15188 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
15189 .ok_or_else(|| LinkError::UnsafePath {
15190 path: dest.display().to_string(),
15191 })?;
15192 #[cfg(unix)]
15193 let parent_dir = open_or_create_dir_nofollow(parent)?;
15194 #[cfg(unix)]
15195 use std::os::fd::AsRawFd as _;
15196 #[cfg(unix)]
15197 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
15198 #[cfg(unix)]
15199 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
15200 None => false,
15201 Some(true) => true,
15202 Some(false) => {
15203 return Err(LinkError::UnsafePath {
15204 path: dest.display().to_string(),
15205 });
15206 }
15207 };
15208
15209 #[cfg(unix)]
15212 let legacy_backup_name = c_name(
15213 format!(".{name}.dbmd-backup").as_bytes(),
15214 &dest.display().to_string(),
15215 )?;
15216 #[cfg(unix)]
15217 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
15218 return Err(LinkError::UnsafePath {
15219 path: parent
15220 .join(format!(".{name}.dbmd-backup"))
15221 .display()
15222 .to_string(),
15223 });
15224 }
15225
15226 let nonce = std::time::SystemTime::now()
15227 .duration_since(std::time::UNIX_EPOCH)
15228 .unwrap_or_default()
15229 .as_nanos();
15230 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
15231 #[cfg(unix)]
15232 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
15233 #[cfg(unix)]
15234 let stage_dir = create_dir_exclusive_at(
15235 parent_dir.as_raw_fd(),
15236 &stage_name,
15237 &dest.display().to_string(),
15238 )?;
15239
15240 let assembled = (|| -> LinkResult<MirrorReport> {
15241 let remote = verified_remote_head(cfg, brain, true)?;
15242 let brain_id = remote.head.brain.clone();
15243 let identity = remote
15244 .identity
15245 .as_ref()
15246 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
15247 let anchor = remote
15248 .anchor
15249 .clone()
15250 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
15251 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
15252 let snapshot_entries = parse_store_pack(pack.clone())?;
15253 let snapshot_count = snapshot_entries.len();
15254 let mut staged_entries = snapshot_entries;
15255 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
15256 for item in &remote.entries {
15257 let mut exact = serde_json::to_vec(&item.entry)
15258 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
15259 exact.push(b'\n');
15260 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
15261 return Err(invalid_feed(
15262 "serialized mirror entry differs from its verified hash",
15263 ));
15264 }
15265 staged_entries.push((
15266 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
15267 exact,
15268 ));
15269 }
15270 let mut identity_bytes = serde_json::to_vec(identity)
15271 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
15272 identity_bytes.push(b'\n');
15273 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
15274 let mut head_bytes = serde_json::to_vec(&json!({
15275 "brain": brain_id,
15276 "headSeq": remote.head.seq,
15277 "feedHash": remote.head.feed_hash,
15278 }))
15279 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
15280 head_bytes.push(b'\n');
15281 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
15282 staged_entries.push((
15283 CONFIG_REL_PATH.to_string(),
15284 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
15285 ));
15286 #[cfg(unix)]
15287 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
15288
15289 Ok(MirrorReport {
15290 brain: brain_id,
15291 head_seq: remote.head.seq,
15292 feed_hash: remote.head.feed_hash,
15293 entries: remote.entries.len() as u64,
15294 pinned: anchor,
15295 files: snapshot_count,
15296 })
15297 })();
15298
15299 let report = match assembled {
15300 Ok(report) => report,
15301 Err(error) => {
15302 #[cfg(unix)]
15303 let _ = remove_tree_at(
15304 parent_dir.as_raw_fd(),
15305 &stage_name,
15306 &dest.display().to_string(),
15307 );
15308 return Err(error);
15309 }
15310 };
15311
15312 #[cfg(unix)]
15313 if let Err(error) =
15314 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
15315 {
15316 let _ = remove_tree_at(
15317 parent_dir.as_raw_fd(),
15318 &stage_name,
15319 &dest.display().to_string(),
15320 );
15321 return Err(error);
15322 }
15323 #[cfg(unix)]
15326 if dest_exists {
15327 remove_tree_at(
15328 parent_dir.as_raw_fd(),
15329 &stage_name,
15330 &dest.display().to_string(),
15331 )?;
15332 }
15333 #[cfg(unix)]
15334 parent_dir.sync_all()?;
15335 Ok(report)
15336}
15337
15338fn verified_remote_head(
15339 cfg: &HubConfig,
15340 brain: &str,
15341 require_full_chain: bool,
15342) -> LinkResult<VerifiedRemote> {
15343 require_hardened_filesystem("verified link.md state")?;
15344 require_safe_ref(brain)?;
15345 let trust_directory = open_trust_dir(cfg)?;
15349 let path = format!("/api/hub/brains/{brain}");
15350 let body = ensure_ok(
15351 request(cfg, "GET", &path, None, Auth::Required)?,
15352 "subscribe",
15353 )?;
15354 let resolved_brain = body
15355 .get("id")
15356 .and_then(Value::as_str)
15357 .filter(|id| crate::ulid::is_ulid(id))
15358 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
15359 .to_string();
15360 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
15361 return Err(invalid_feed(
15362 "brain card id differs from the explicitly requested brain id",
15363 ));
15364 }
15365 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
15370 let (pinned, alias_binding) =
15371 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
15372 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
15373 let advertised_hash = body
15374 .get("feedHash")
15375 .and_then(Value::as_str)
15376 .map(str::to_string);
15377 let updated_at = body
15378 .get("updatedAt")
15379 .and_then(Value::as_str)
15380 .map(str::to_string);
15381 if let Some(pin) = &pinned {
15382 if seq < pin.head_seq {
15383 return Err(invalid_feed(format!(
15384 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
15385 pin.head_seq
15386 )));
15387 }
15388 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
15389 return Err(invalid_feed(
15390 "feed equivocation: the checkpoint sequence now has a different hash",
15391 ));
15392 }
15393 }
15394 if seq == 0 {
15395 if advertised_hash.is_some() {
15396 return Err(invalid_feed("an empty feed advertised a head hash"));
15397 }
15398 let identity: FeedIdentity = serde_json::from_value(
15399 body.get("identity")
15400 .cloned()
15401 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
15402 )
15403 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
15404 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
15405 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
15410 save_canonical_pin_and_alias(
15411 cfg,
15412 &trust_directory,
15413 brain,
15414 &resolved_brain,
15415 TrustState {
15416 v: 2,
15417 origin: normalized_origin(&cfg.hub)?,
15418 requested: resolved_brain.clone(),
15419 brain: resolved_brain.clone(),
15420 home: None,
15421 anchor: anchor.clone(),
15422 current: format!("ed25519:{}", identity.fingerprint),
15423 head_seq: 0,
15424 feed_hash: None,
15425 rotations: identity.rotations.clone(),
15426 hub_signer: None,
15427 protocol_profile: None,
15428 },
15429 alias_binding.as_ref(),
15430 )?;
15431 return Ok(VerifiedRemote {
15432 head: Head {
15433 brain: resolved_brain,
15434 seq,
15435 updated_at,
15436 feed_hash: None,
15437 verified: true,
15438 },
15439 identity: Some(identity),
15440 head_entry: None,
15441 entries: Vec::new(),
15442 anchor: Some(anchor),
15443 });
15444 }
15445 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
15446 return Err(invalid_feed(
15447 "non-empty feed did not advertise a valid SHA-256 head",
15448 ));
15449 }
15450
15451 let replay_head_only = !require_full_chain
15455 && pinned
15456 .as_ref()
15457 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
15458 let mut after = if replay_head_only {
15459 seq - 1
15460 } else if require_full_chain || pinned.is_none() {
15461 0
15462 } else {
15463 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
15464 };
15465 let mut expected_seq = after + 1;
15466 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
15467 None
15468 } else {
15469 pinned
15470 .as_ref()
15471 .and_then(|checkpoint| checkpoint.feed_hash.clone())
15472 };
15473 let mut identity: Option<FeedIdentity> = None;
15474 let mut anchor: Option<String> = None;
15475 let mut head_entry: Option<FeedItem> = None;
15476 let mut all_entries = Vec::new();
15477 let mut observed_entries = Vec::new();
15478 let replay_count = seq
15479 .checked_sub(after)
15480 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
15481 if replay_count > MAX_FEED_REPLAY_ENTRIES {
15482 return Err(invalid_feed(format!(
15483 "feed replay requires {replay_count} entries, over the client cap"
15484 )));
15485 }
15486 let mut replay_bytes = 0u64;
15487
15488 loop {
15489 let feed_bytes = ensure_raw_ok(
15490 request_raw(
15491 cfg,
15492 "GET",
15493 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
15494 None,
15495 Auth::Required,
15496 MAX_FEED_RESPONSE_BYTES,
15497 )?,
15498 "subscribe feed",
15499 )?;
15500 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
15501 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
15502 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
15503 return Err(invalid_feed("brain card and feed head disagree"));
15504 }
15505 if feed.entries.len() > FEED_PAGE_LIMIT {
15506 return Err(invalid_feed("feed page exceeds the requested entry limit"));
15507 }
15508 if feed.scope_limited {
15509 if require_full_chain {
15510 return Err(invalid_feed(
15511 "path-scoped grants cannot verify a full snapshot chain",
15512 ));
15513 }
15514 return Ok(VerifiedRemote {
15515 head: Head {
15516 brain: resolved_brain,
15517 seq,
15518 updated_at,
15519 feed_hash: advertised_hash,
15520 verified: false,
15521 },
15522 identity: None,
15523 head_entry: None,
15524 entries: Vec::new(),
15525 anchor: None,
15526 });
15527 }
15528 let page_identity = feed
15529 .identity
15530 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
15531 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
15532 if identity
15533 .as_ref()
15534 .is_some_and(|existing| existing != &page_identity)
15535 {
15536 return Err(invalid_feed("identity changed while reading the feed"));
15537 }
15538 if anchor
15539 .as_ref()
15540 .is_some_and(|existing| existing != &page_anchor)
15541 {
15542 return Err(invalid_feed(
15543 "identity anchor changed while reading the feed",
15544 ));
15545 }
15546 identity = Some(page_identity.clone());
15547 if anchor.is_none() {
15548 anchor = Some(page_anchor);
15549 }
15550 if feed.entries.is_empty() {
15551 return Err(invalid_feed("feed page was empty before the signed head"));
15552 }
15553
15554 for item in feed.entries {
15555 if item.entry.seq != expected_seq {
15556 return Err(invalid_feed(format!(
15557 "expected entry {expected_seq}, feed served {}",
15558 item.entry.seq
15559 )));
15560 }
15561 if item.entry.seq > seq {
15562 return Err(invalid_feed("feed advanced past the card snapshot"));
15563 }
15564 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
15565 return Err(invalid_feed(format!(
15566 "entry {} does not chain to the local checkpoint",
15567 item.entry.seq
15568 )));
15569 }
15570 verify_feed_item(&item, &page_identity)?;
15571 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
15572 replay_bytes = replay_bytes.saturating_add(
15573 serde_json::to_vec(&item)
15574 .map_err(|_| invalid_feed("could not size feed entry"))?
15575 .len() as u64,
15576 );
15577 if replay_bytes > MAX_FEED_REPLAY_BYTES {
15578 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
15579 }
15580 previous_hash = Some(item.hash.clone());
15581 after = item.entry.seq;
15582 expected_seq = expected_seq
15583 .checked_add(1)
15584 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
15585 if require_full_chain {
15586 all_entries.push(item.clone());
15587 }
15588 observed_entries.push(item.clone());
15589 head_entry = Some(item);
15590 }
15591 if after == seq {
15592 break;
15593 }
15594 }
15595
15596 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
15597 return Err(invalid_feed(
15598 "verified chain does not converge on the advertised head",
15599 ));
15600 }
15601 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
15602 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
15603 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
15604 save_canonical_pin_and_alias(
15605 cfg,
15606 &trust_directory,
15607 brain,
15608 &resolved_brain,
15609 TrustState {
15610 v: 2,
15611 origin: normalized_origin(&cfg.hub)?,
15612 requested: resolved_brain.clone(),
15613 brain: resolved_brain.clone(),
15614 home: None,
15615 anchor: anchor.clone(),
15616 current: format!("ed25519:{}", identity.fingerprint),
15617 head_seq: seq,
15618 feed_hash: advertised_hash.clone(),
15619 rotations: identity.rotations.clone(),
15620 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
15621 protocol_profile: pinned
15622 .as_ref()
15623 .and_then(|state| state.protocol_profile.clone()),
15624 },
15625 alias_binding.as_ref(),
15626 )?;
15627 Ok(VerifiedRemote {
15628 head: Head {
15629 brain: resolved_brain,
15630 seq,
15631 updated_at,
15632 feed_hash: advertised_hash,
15633 verified: true,
15634 },
15635 identity: Some(identity),
15636 head_entry,
15637 entries: all_entries,
15638 anchor: Some(anchor),
15639 })
15640}
15641
15642pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
15647 if let Some(verified) = v2_verified_head(cfg, brain)? {
15648 let observation = Head {
15649 brain: verified.brain_id.clone(),
15650 seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
15651 updated_at: verified
15652 .pointer
15653 .as_ref()
15654 .map(|pointer| pointer.signed_at.clone()),
15655 feed_hash: verified
15656 .pointer
15657 .as_ref()
15658 .map(|pointer| pointer.feed_hash.clone()),
15659 verified: true,
15660 };
15661 accept_v2_head(cfg, &verified)?;
15662 return Ok(observation);
15663 }
15664 Ok(verified_remote_head(cfg, brain, false)?.head)
15665}
15666
15667#[cfg(test)]
15668mod tests {
15669 use super::*;
15670
15671 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
15672
15673 fn drain_test_http_request(stream: &mut std::net::TcpStream) {
15674 use std::io::Read as _;
15675
15676 stream
15677 .set_read_timeout(Some(std::time::Duration::from_secs(2)))
15678 .unwrap();
15679 let mut request = Vec::new();
15680 let mut chunk = [0_u8; 4096];
15681 loop {
15682 let read = stream.read(&mut chunk).unwrap();
15683 assert!(read > 0, "test client closed before its request completed");
15684 request.extend_from_slice(&chunk[..read]);
15685 let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") else {
15686 continue;
15687 };
15688 let headers = String::from_utf8_lossy(&request[..header_end]);
15689 let content_length = headers
15690 .lines()
15691 .find_map(|line| {
15692 let (name, value) = line.split_once(':')?;
15693 name.eq_ignore_ascii_case("content-length")
15694 .then(|| value.trim().parse::<usize>().unwrap())
15695 })
15696 .unwrap_or(0);
15697 if request.len() >= header_end + 4 + content_length {
15698 return;
15699 }
15700 }
15701 }
15702
15703 fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
15704 json!({
15705 "sha256": "a".repeat(64),
15706 "bytes": 10,
15707 "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
15708 })
15709 }
15710
15711 #[test]
15712 fn upload_reservations_batch_by_count_and_by_size() {
15713 let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
15717 let batches = batch_upload_declarations(declarations.clone());
15718
15719 assert!(batches.len() > 1, "5,000 blobs must not ride one request");
15720 for batch in &batches {
15721 assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
15722 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15723 .expect("batch serializes")
15724 .len();
15725 assert!(
15726 bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
15727 "batch body {bytes} exceeds the reservation budget"
15728 );
15729 }
15730 let flattened: Vec<Value> = batches.into_iter().flatten().collect();
15731 assert_eq!(
15732 flattened, declarations,
15733 "batching must preserve the set and order"
15734 );
15735 }
15736
15737 #[test]
15738 fn only_load_shaped_hub_answers_are_worth_asking_again() {
15739 for status in [408, 429, 500, 502, 503, 504] {
15744 assert!(is_retryable_hub_status(status), "{status} is load-shaped");
15745 }
15746 for status in [400, 401, 403, 404, 409, 413, 422] {
15747 assert!(
15748 !is_retryable_hub_status(status),
15749 "{status} states something about the request"
15750 );
15751 }
15752 let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
15754 assert!(total >= 60_000, "backoff totals only {total}ms");
15755 }
15756
15757 #[test]
15758 fn a_batch_shares_a_connection_only_within_one_authority() {
15759 let cfg = HubConfig {
15764 hub: "https://www.sevrahq.com".to_string(),
15765 key: Some("k".to_string()),
15766 agent_key: None,
15767 brain_key: None,
15768 state_dir: PathBuf::from("."),
15769 store_selected: false,
15770 };
15771 assert!(shared_staging_agent(&cfg, &[]).is_none());
15772 assert!(
15773 shared_staging_agent(
15774 &cfg,
15775 &[
15776 "https://one.example.com/a?sig=1",
15777 "https://two.example.com/b?sig=2",
15778 ]
15779 )
15780 .is_none(),
15781 "two authorities must not share a pinned pool"
15782 );
15783 assert!(
15784 shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
15785 "an unsafe object-store URL must not produce an agent"
15786 );
15787 assert!(
15788 shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
15789 "credentials in the URL must not produce an agent"
15790 );
15791 }
15792
15793 #[test]
15794 fn a_staged_change_states_only_operations_and_blobs() {
15795 let operations = vec![json!({
15799 "op": "put",
15800 "path": "records/a.md",
15801 "blob": "a".repeat(64),
15802 "bytes": 3,
15803 })];
15804 let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
15805 let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
15806 let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
15807 let keys: Vec<&str> = parsed
15808 .as_object()
15809 .expect("manifest is an object")
15810 .keys()
15811 .map(String::as_str)
15812 .collect();
15813 assert_eq!(keys, ["blobs", "operations"]);
15814 assert_eq!(parsed["operations"], Value::Array(operations));
15815 assert_eq!(parsed["blobs"], blobs);
15816 }
15817
15818 #[test]
15819 fn a_staged_push_signs_the_change_not_the_transport() {
15820 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15825 let staged = json!({
15826 "mutation_id": "dbmd-1",
15827 "rebase": "strict",
15828 "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
15829 });
15830 let view = v2_signed_request_view(&staged, &operations);
15831 assert_eq!(view["operations"], Value::Array(operations.clone()));
15832 assert!(view.get("staged_change").is_none());
15833 assert_eq!(view["mutation_id"], staged["mutation_id"]);
15834
15835 let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
15836 assert_eq!(v2_signed_request_view(&inline, &[]), inline);
15837 }
15838
15839 #[test]
15840 fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
15841 let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
15842 let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
15843 .expect_err("an oversized change must not be staged");
15844 assert!(
15845 matches!(error, LinkError::PushTooLarge { .. }),
15846 "expected a size refusal, got {error:?}"
15847 );
15848 }
15849
15850 #[test]
15851 fn a_push_that_fits_the_request_is_left_inline() {
15852 let cfg = HubConfig {
15856 hub: "http://127.0.0.1:9".to_string(),
15857 key: Some("k".to_string()),
15858 agent_key: None,
15859 brain_key: None,
15860 state_dir: PathBuf::from("."),
15861 store_selected: false,
15862 };
15863 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15864 let mut body = json!({
15865 "mutation_id": "dbmd-1",
15866 "operations": operations,
15867 "blobs": [],
15868 });
15869 stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
15870 assert!(body.get("staged_change").is_none());
15871 assert_eq!(body["operations"], Value::Array(operations));
15872 }
15873
15874 #[test]
15875 fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
15876 let declarations: Vec<Value> = (0..2_000)
15880 .map(|index| {
15881 json!({
15882 "sha256": "a".repeat(64),
15883 "bytes": 10,
15884 "coordinates": (0..24)
15885 .map(|slot| format!(
15886 "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
15887 ))
15888 .collect::<Vec<_>>(),
15889 })
15890 })
15891 .collect();
15892 let batches = batch_upload_declarations(declarations);
15893 assert!(
15894 batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
15895 "wide coordinate sets must bound the batch by size"
15896 );
15897 for batch in &batches {
15898 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15899 .expect("batch serializes")
15900 .len();
15901 assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
15902 }
15903 }
15904
15905 #[test]
15906 fn a_small_push_still_rides_exactly_one_request() {
15907 let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
15908 assert_eq!(batch_upload_declarations(declarations).len(), 1);
15909 assert!(batch_upload_declarations(Vec::new()).is_empty());
15910 }
15911
15912 #[test]
15913 fn exact_source_move_becomes_one_provenance_preserving_rename() {
15914 let hash = "a".repeat(64);
15915 let operations = vec![
15916 json!({
15917 "op": "put",
15918 "path": "sources/curated/item.md",
15919 "expected": { "kind": "absent" },
15920 "blob": hash,
15921 "bytes": 19,
15922 }),
15923 json!({
15924 "op": "delete",
15925 "path": "sources/inbox/item.md",
15926 "expected": { "kind": "blob", "hash": hash },
15927 }),
15928 ];
15929
15930 assert_eq!(
15931 infer_exact_source_promotions(operations),
15932 vec![json!({
15933 "op": "rename",
15934 "from": "sources/inbox/item.md",
15935 "to": "sources/curated/item.md",
15936 "expected_from": { "kind": "blob", "hash": hash },
15937 "expected_to": { "kind": "absent" },
15938 "blob": hash,
15939 "bytes": 19,
15940 })]
15941 );
15942 }
15943
15944 #[test]
15945 fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
15946 let hash = "b".repeat(64);
15947 let operations = vec![
15948 json!({
15949 "op": "delete",
15950 "path": "sources/inbox/a.md",
15951 "expected": { "kind": "blob", "hash": hash },
15952 }),
15953 json!({
15954 "op": "delete",
15955 "path": "sources/inbox/b.md",
15956 "expected": { "kind": "blob", "hash": hash },
15957 }),
15958 json!({
15959 "op": "put",
15960 "path": "sources/curated/item.md",
15961 "expected": { "kind": "absent" },
15962 "blob": hash,
15963 "bytes": 19,
15964 }),
15965 ];
15966
15967 assert_eq!(
15968 infer_exact_source_promotions(operations.clone()),
15969 operations,
15970 "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
15971 );
15972 }
15973
15974 #[test]
15975 fn accepted_source_promotion_advances_the_local_baseline_exactly() {
15976 let hash = "c".repeat(64);
15977 let mut candidate = std::collections::BTreeMap::from([(
15978 "sources/inbox/item.md".to_string(),
15979 V2BaselineFile {
15980 sha256: hash.clone(),
15981 bytes: 19,
15982 proof: None,
15983 },
15984 )]);
15985 let mut candidate_assets = std::collections::BTreeMap::new();
15986 let operations = vec![
15987 json!({
15988 "op": "rename",
15989 "from": "sources/inbox/item.md",
15990 "to": "sources/curated/item.md",
15991 "expected_from": { "kind": "blob", "hash": hash },
15992 "expected_to": { "kind": "absent" },
15993 "blob": hash,
15994 "bytes": 19,
15995 }),
15996 json!({
15997 "op": "put",
15998 "path": "records/rsvps/item.md",
15999 "expected": { "kind": "absent" },
16000 "blob": "d".repeat(64),
16001 "bytes": 23,
16002 }),
16003 ];
16004
16005 assert!(!apply_generated_v2_operations(
16006 &operations,
16007 &std::collections::BTreeMap::new(),
16008 &mut candidate,
16009 &mut candidate_assets,
16010 )
16011 .unwrap());
16012 assert!(!candidate.contains_key("sources/inbox/item.md"));
16013 assert_eq!(
16014 candidate
16015 .get("sources/curated/item.md")
16016 .map(|file| (&file.sha256, file.bytes)),
16017 Some((&hash, 19))
16018 );
16019 assert_eq!(
16020 candidate
16021 .get("records/rsvps/item.md")
16022 .map(|file| (file.sha256.as_str(), file.bytes)),
16023 Some((
16024 "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
16025 23
16026 ))
16027 );
16028 }
16029
16030 fn merge_fixture(
16031 base: Option<&str>,
16032 remote: Option<&str>,
16033 local: Option<&str>,
16034 keep_local: bool,
16035 ) -> V2PulledMerge<String> {
16036 let map = |value: Option<&str>| {
16037 value
16038 .map(|value| [("records/a.md".to_string(), value.to_string())])
16039 .into_iter()
16040 .flatten()
16041 .collect::<std::collections::BTreeMap<_, _>>()
16042 };
16043 merge_v2_pulled_records(
16044 &map(base),
16045 &map(remote),
16046 &map(local),
16047 |value, _| value.clone(),
16048 |value, _| value.clone(),
16049 |_| keep_local,
16050 )
16051 }
16052
16053 #[test]
16054 fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
16055 let path = "records/a.md".to_string();
16056
16057 let local_add = merge_fixture(None, None, Some("local"), false);
16058 assert_eq!(
16059 local_add.records.get(&path).map(String::as_str),
16060 Some("local")
16061 );
16062 assert!(local_add.accept_remote.is_empty());
16063 assert!(local_add.conflicts.is_empty());
16064
16065 let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
16066 assert_eq!(
16067 local_edit.records.get(&path).map(String::as_str),
16068 Some("local")
16069 );
16070 assert!(local_edit.accept_remote.is_empty());
16071 assert!(local_edit.conflicts.is_empty());
16072
16073 let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
16074 assert!(!local_delete.records.contains_key(&path));
16075 assert!(local_delete.accept_remote.is_empty());
16076 assert!(local_delete.conflicts.is_empty());
16077
16078 let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
16079 assert_eq!(
16080 remote_edit.records.get(&path).map(String::as_str),
16081 Some("remote")
16082 );
16083 assert!(remote_edit.accept_remote.contains(&path));
16084 assert!(remote_edit.conflicts.is_empty());
16085
16086 let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
16087 assert!(!remote_delete.records.contains_key(&path));
16088 assert!(remote_delete.accept_remote.contains(&path));
16089 assert!(remote_delete.conflicts.is_empty());
16090
16091 let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
16092 assert_eq!(
16093 same_edit.records.get(&path).map(String::as_str),
16094 Some("same")
16095 );
16096 assert!(same_edit.accept_remote.contains(&path));
16097 assert!(same_edit.conflicts.is_empty());
16098
16099 let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
16100 assert_eq!(conflict.conflicts, vec![path.clone()]);
16101 assert_eq!(
16102 conflict.records.get(&path).map(String::as_str),
16103 Some("local")
16104 );
16105 assert!(conflict.accept_remote.is_empty());
16106
16107 let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
16108 assert_eq!(
16109 kept_home.records.get(&path).map(String::as_str),
16110 Some("local")
16111 );
16112 assert!(kept_home.accept_remote.is_empty());
16113 assert!(kept_home.conflicts.is_empty());
16114 }
16115
16116 #[test]
16117 fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
16118 let path = "sources/report.pdf";
16119 let record = crate::AssetRecord {
16120 path: path.to_string(),
16121 sha256: "a".repeat(64),
16122 bytes: 42,
16123 media_type: "application/pdf".to_string(),
16124 wrappers: vec!["gzip".to_string()],
16125 required: true,
16126 };
16127 let mut remote = V2BaselineAsset {
16128 blob_sha256: record.sha256.clone(),
16129 bytes: record.bytes,
16130 media_type: record.media_type.clone(),
16131 wrappers: record.wrappers.clone(),
16132 required: record.required,
16133 disposition: "withheld".to_string(),
16134 leaf_hash: "b".repeat(64),
16135 };
16136
16137 assert!(v2_asset_resumes_hosting(
16138 Some(&remote),
16139 path,
16140 &record,
16141 "hosted"
16142 ));
16143 assert!(!v2_asset_resumes_hosting(
16144 Some(&remote),
16145 path,
16146 &record,
16147 "withheld"
16148 ));
16149
16150 remote.disposition = "hosted".to_string();
16151 assert!(!v2_asset_resumes_hosting(
16152 Some(&remote),
16153 path,
16154 &record,
16155 "hosted"
16156 ));
16157
16158 remote.disposition = "withheld".to_string();
16159 remote.blob_sha256 = "c".repeat(64);
16160 assert!(!v2_asset_resumes_hosting(
16161 Some(&remote),
16162 path,
16163 &record,
16164 "hosted"
16165 ));
16166 assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
16167 }
16168
16169 #[test]
16170 fn v2_fresh_clone_preserves_only_exact_inherited_withheld_asset_absence() {
16171 let path = "sources/report.pdf";
16172 let record = crate::AssetRecord {
16173 path: path.to_string(),
16174 sha256: "a".repeat(64),
16175 bytes: 42,
16176 media_type: "application/pdf".to_string(),
16177 wrappers: vec!["records/report.md".to_string()],
16178 required: true,
16179 };
16180 let mut base = V2BaselineAsset {
16181 blob_sha256: record.sha256.clone(),
16182 bytes: record.bytes,
16183 media_type: record.media_type.clone(),
16184 wrappers: record.wrappers.clone(),
16185 required: record.required,
16186 disposition: "withheld".to_string(),
16187 leaf_hash: "b".repeat(64),
16188 };
16189
16190 assert!(v2_asset_inherits_withheld_absence(
16191 Some(&base),
16192 Some(&record),
16193 Some(&record),
16194 false,
16195 ));
16196 assert!(!v2_asset_inherits_withheld_absence(
16197 Some(&base),
16198 Some(&record),
16199 Some(&record),
16200 true,
16201 ));
16202
16203 base.disposition = "hosted".to_string();
16204 assert!(!v2_asset_inherits_withheld_absence(
16205 Some(&base),
16206 Some(&record),
16207 Some(&record),
16208 false,
16209 ));
16210
16211 base.disposition = "withheld".to_string();
16212 let mut changed = record.clone();
16213 changed.bytes += 1;
16214 assert!(!v2_asset_inherits_withheld_absence(
16215 Some(&base),
16216 Some(&record),
16217 Some(&changed),
16218 false,
16219 ));
16220 assert!(!v2_asset_inherits_withheld_absence(
16221 None,
16222 None,
16223 Some(&record),
16224 false,
16225 ));
16226 }
16227
16228 #[test]
16229 fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
16230 let path = "records/team/alpha.md".to_string();
16231 let deleted_path = "records/team/deleted.md".to_string();
16232 let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
16233 sha256,
16234 bytes,
16235 file: None,
16236 };
16237 let files = vec![
16238 V2ConflictFile {
16239 path: path.clone(),
16240 base: coordinate(None, None),
16241 local: coordinate(Some("b".repeat(64)), Some(7)),
16242 remote: coordinate(Some("a".repeat(64)), Some(5)),
16243 },
16244 V2ConflictFile {
16245 path: deleted_path.clone(),
16246 base: coordinate(Some("c".repeat(64)), Some(9)),
16247 local: coordinate(Some("d".repeat(64)), Some(11)),
16248 remote: coordinate(None, None),
16249 },
16250 ];
16251 let proven = V2BaselineFile {
16252 sha256: "a".repeat(64),
16253 bytes: 5,
16254 proof: None,
16255 };
16256 let current = [(path.clone(), proven.clone())]
16257 .into_iter()
16258 .collect::<std::collections::BTreeMap<_, _>>();
16259
16260 let (selected, deleted) = v2_take_remote_selection(&files, ¤t).unwrap();
16261 assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
16262 assert_eq!(deleted, vec![deleted_path.clone()]);
16263
16264 let changed = [(
16265 path.clone(),
16266 V2BaselineFile {
16267 sha256: "e".repeat(64),
16268 bytes: 5,
16269 proof: None,
16270 },
16271 )]
16272 .into_iter()
16273 .collect::<std::collections::BTreeMap<_, _>>();
16274 assert!(v2_take_remote_selection(&files, &changed).is_err());
16275
16276 let resurrected = [
16277 (path, proven),
16278 (
16279 deleted_path,
16280 V2BaselineFile {
16281 sha256: "f".repeat(64),
16282 bytes: 13,
16283 proof: None,
16284 },
16285 ),
16286 ]
16287 .into_iter()
16288 .collect::<std::collections::BTreeMap<_, _>>();
16289 assert!(v2_take_remote_selection(&files, &resurrected).is_err());
16290 }
16291
16292 #[cfg(target_os = "linux")]
16293 #[test]
16294 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
16295 use std::os::fd::AsRawFd as _;
16296
16297 let sandbox = tempfile::TempDir::new().unwrap();
16298 let parent = std::fs::File::open(sandbox.path()).unwrap();
16299 let stage = std::ffi::CString::new("stage").unwrap();
16300 let destination = std::ffi::CString::new("brain").unwrap();
16301
16302 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
16303 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
16304 install_stage_at(
16305 parent.as_raw_fd(),
16306 stage.as_c_str(),
16307 destination.as_c_str(),
16308 false,
16309 )
16310 .unwrap();
16311 assert!(!sandbox.path().join("stage").exists());
16312 assert_eq!(
16313 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
16314 b"created"
16315 );
16316
16317 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
16318 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
16319 install_stage_at(
16320 parent.as_raw_fd(),
16321 stage.as_c_str(),
16322 destination.as_c_str(),
16323 true,
16324 )
16325 .unwrap();
16326 assert_eq!(
16327 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
16328 b"replacement"
16329 );
16330 assert_eq!(
16331 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
16332 b"created",
16333 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
16334 );
16335 }
16336
16337 struct SignedRemoteFixture {
16338 card: String,
16339 feed: String,
16340 key: AgentSigningKey,
16341 identity: FeedIdentity,
16342 }
16343
16344 fn signed_remote_fixture() -> SignedRemoteFixture {
16345 let rng = ring::rand::SystemRandom::new();
16346 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16347 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16348 let (public_key, multikey) = public_identity_for(&pair);
16349 let identity = FeedIdentity {
16350 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16351 public_key_spki: public_key.clone(),
16352 previous: Vec::new(),
16353 rotations: Vec::new(),
16354 };
16355 let mut entry = FeedEntry {
16356 v: 1,
16357 seq: 1,
16358 ts: "2026-07-30T12:00:00.000Z".to_string(),
16359 brain: multikey.clone(),
16360 public_key: public_key.clone(),
16361 kind: "push".to_string(),
16362 op: "snapshot".to_string(),
16363 pack_sha256: "a".repeat(64),
16364 files: Vec::new(),
16365 removed: Vec::new(),
16366 prev_entry_hash: None,
16367 sig: String::new(),
16368 };
16369 let unsigned = UnsignedFeedEntry {
16370 v: entry.v,
16371 seq: entry.seq,
16372 ts: &entry.ts,
16373 brain: &entry.brain,
16374 public_key: &entry.public_key,
16375 kind: &entry.kind,
16376 op: &entry.op,
16377 pack_sha256: &entry.pack_sha256,
16378 files: &entry.files,
16379 removed: &entry.removed,
16380 prev_entry_hash: &entry.prev_entry_hash,
16381 };
16382 entry.sig =
16383 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16384 let mut exact = serde_json::to_vec(&entry).unwrap();
16385 exact.push(b'\n');
16386 let hash = content_sha256(&exact);
16387 let card = json!({
16388 "id": TEST_BRAIN_ID,
16389 "headSeq": 1,
16390 "feedHash": hash,
16391 "identity": identity.clone(),
16392 })
16393 .to_string();
16394 let feed = json!({
16395 "headSeq": 1,
16396 "feedHash": hash,
16397 "identity": identity.clone(),
16398 "entries": [{"hash": hash, "entry": entry}],
16399 "scopeLimited": false,
16400 })
16401 .to_string();
16402 SignedRemoteFixture {
16403 card,
16404 feed,
16405 key: AgentSigningKey {
16406 pkcs8: pkcs8.as_ref().to_vec(),
16407 multikey,
16408 public_key_spki: public_key,
16409 },
16410 identity,
16411 }
16412 }
16413
16414 #[test]
16415 fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
16416 let file = |path: &str, byte: char| FeedFile {
16417 path: path.to_string(),
16418 sha256: byte.to_string().repeat(64),
16419 bytes: 1,
16420 };
16421 let a0 = file("records/a.md", 'a');
16422 let a1 = file("records/a.md", 'b');
16423 let stable = file("records/stable.md", 'c');
16424 let added = file("records/added.md", 'd');
16425 let removed_file = file("records/removed.md", 'e');
16426 let previous = vec![a0, stable.clone(), removed_file.clone()];
16427 let resulting = vec![a1.clone(), stable.clone(), added.clone()];
16428 let removed = vec![removed_file.path.clone()];
16429
16430 assert_eq!(
16431 verify_v1_manifest_disclosure(
16432 "edit",
16433 &previous,
16434 &resulting,
16435 &[a1.clone(), added.clone()],
16436 &removed,
16437 ),
16438 Ok(())
16439 );
16440 assert_eq!(
16441 verify_v1_manifest_disclosure(
16442 "edit",
16443 &previous,
16444 &resulting,
16445 &[stable.clone(), added.clone(), a1.clone()],
16446 &removed,
16447 ),
16448 Ok(())
16449 );
16450 assert_eq!(
16451 verify_v1_manifest_disclosure(
16452 "edit",
16453 &previous,
16454 &resulting,
16455 std::slice::from_ref(&added),
16456 &removed,
16457 ),
16458 Err(V1DisclosureError::EditMissingChange)
16459 );
16460 assert_eq!(
16461 verify_v1_manifest_disclosure(
16462 "edit",
16463 &previous,
16464 &resulting,
16465 &[file("records/a.md", 'f'), added.clone()],
16466 &removed,
16467 ),
16468 Err(V1DisclosureError::EditFalseFile)
16469 );
16470 assert_eq!(
16471 verify_v1_manifest_disclosure(
16472 "edit",
16473 &previous,
16474 &resulting,
16475 &[a1.clone(), added.clone()],
16476 &[],
16477 ),
16478 Err(V1DisclosureError::RemovedMismatch)
16479 );
16480 assert_eq!(
16481 verify_v1_manifest_disclosure(
16482 "push",
16483 &previous,
16484 &resulting,
16485 &[added.clone(), stable, a1],
16486 &removed,
16487 ),
16488 Ok(())
16489 );
16490 assert_eq!(
16491 verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
16492 Err(V1DisclosureError::PushManifestMismatch)
16493 );
16494 }
16495
16496 #[test]
16497 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
16498 let fixture = signed_remote_fixture();
16499 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
16500 let item = feed["entries"][0].to_string();
16501 let oversized_page = format!(
16502 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
16503 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
16504 .collect::<Vec<_>>()
16505 .join(",")
16506 );
16507 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
16508
16509 let oversized_identity = format!(
16510 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
16511 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
16512 .collect::<Vec<_>>()
16513 .join(",")
16514 );
16515 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
16516
16517 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
16518 let oversized_entry = format!(
16519 "{{\"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\"}}",
16520 "a".repeat(64),
16521 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
16522 .collect::<Vec<_>>()
16523 .join(",")
16524 );
16525 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
16526 }
16527
16528 #[test]
16529 fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
16530 let id = "01arz3ndektsv4rrffq69g5fav";
16531 let digest = "a".repeat(64);
16532 assert_eq!(
16533 V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
16534 V2BulkConfirmation {
16535 id: id.to_string(),
16536 digest,
16537 }
16538 );
16539 for invalid in [
16540 "",
16541 "01arz3ndektsv4rrffq69g5fav",
16542 "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
16543 "01arz3ndektsv4rrffq69g5fav:ABCDEF",
16544 "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
16545 ] {
16546 assert!(matches!(
16547 V2BulkConfirmation::parse(invalid),
16548 Err(LinkError::InvalidPack { .. })
16549 ));
16550 }
16551 }
16552
16553 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
16554 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
16555 use std::net::TcpListener;
16556
16557 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16558 let url = format!("http://{}", listener.local_addr().unwrap());
16559 let handle = std::thread::spawn(move || {
16560 for (status, body) in responses {
16561 let (stream, _) = listener.accept().unwrap();
16562 let mut reader = BufReader::new(stream);
16563 let mut line = String::new();
16564 reader.read_line(&mut line).unwrap();
16565 let mut content_length = 0usize;
16566 loop {
16567 line.clear();
16568 reader.read_line(&mut line).unwrap();
16569 if line == "\r\n" || line == "\n" || line.is_empty() {
16570 break;
16571 }
16572 if let Some((name, value)) = line.split_once(':') {
16573 if name.eq_ignore_ascii_case("content-length") {
16574 content_length = value.trim().parse().unwrap();
16575 }
16576 }
16577 }
16578 let mut request_body = vec![0_u8; content_length];
16579 reader.read_exact(&mut request_body).unwrap();
16580 let response = format!(
16581 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
16582 body.len()
16583 );
16584 reader.get_mut().write_all(response.as_bytes()).unwrap();
16585 }
16586 });
16587 (url, handle)
16588 }
16589
16590 fn routed_json_hub(
16591 requests: usize,
16592 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
16593 ) -> (String, std::thread::JoinHandle<()>) {
16594 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
16595 use std::net::TcpListener;
16596
16597 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16598 let url = format!("http://{}", listener.local_addr().unwrap());
16599 let handle = std::thread::spawn(move || {
16600 for _ in 0..requests {
16601 let (stream, _) = listener.accept().unwrap();
16602 let mut reader = BufReader::new(stream);
16603 let mut line = String::new();
16604 reader.read_line(&mut line).unwrap();
16605 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
16606 let mut content_length = 0usize;
16607 loop {
16608 line.clear();
16609 reader.read_line(&mut line).unwrap();
16610 if line == "\r\n" || line == "\n" || line.is_empty() {
16611 break;
16612 }
16613 if let Some((name, value)) = line.split_once(':') {
16614 if name.eq_ignore_ascii_case("content-length") {
16615 content_length = value.trim().parse().unwrap();
16616 }
16617 }
16618 }
16619 let mut request_body = vec![0_u8; content_length];
16620 reader.read_exact(&mut request_body).unwrap();
16621 let (status, body) = respond(&path);
16622 let response = format!(
16623 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
16624 body.len()
16625 );
16626 reader.get_mut().write_all(response.as_bytes()).unwrap();
16627 }
16628 });
16629 (url, handle)
16630 }
16631
16632 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
16633 HubConfig {
16634 hub,
16635 key: Some("test-key".to_string()),
16636 agent_key: None,
16637 brain_key: None,
16638 state_dir,
16639 store_selected: false,
16640 }
16641 }
16642
16643 #[cfg(any(unix, windows))]
16644 #[test]
16645 fn an_expired_asset_capability_is_refreshed_without_losing_cached_progress() {
16646 use std::sync::{Arc, Mutex};
16647
16648 let bytes = b"immutable asset bytes".to_vec();
16649 let sha256 = content_sha256(&bytes);
16650 let commit_hash = "c".repeat(64);
16651 let base_url = Arc::new(Mutex::new(String::new()));
16652 let server_base = Arc::clone(&base_url);
16653 let object_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16654 let server_attempt = Arc::clone(&object_attempt);
16655 let response_bytes = bytes.clone();
16656 let response_sha = sha256.clone();
16657 let response_commit = commit_hash.clone();
16658 let (hub, server) = routed_json_hub(4, move |path| {
16659 if path.contains("/v2/assets/downloads") {
16660 let attempt = server_attempt.load(std::sync::atomic::Ordering::SeqCst) + 1;
16661 let url = format!("{}/asset/{attempt}", server_base.lock().unwrap());
16662 return (
16663 200,
16664 json!({
16665 "v": 2,
16666 "commit": response_commit,
16667 "downloads": [{
16668 "path": "assets/proof.bin",
16669 "sha256": response_sha,
16670 "bytes": response_bytes.len(),
16671 "url": url,
16672 "method": "GET"
16673 }]
16674 })
16675 .to_string(),
16676 );
16677 }
16678 let attempt = server_attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16679 if attempt == 0 {
16680 (403, "{}".to_string())
16681 } else {
16682 (200, String::from_utf8(response_bytes.clone()).unwrap())
16683 }
16684 });
16685 *base_url.lock().unwrap() = hub.clone();
16686
16687 let temp = tempfile::tempdir().unwrap();
16688 let cache = temp.path().join("cache");
16689 std::fs::create_dir(&cache).unwrap();
16690 let cfg = test_hub_config(hub, temp.path().to_path_buf());
16691 let pointer = V2PointerBody {
16692 v: 2,
16693 brain: TEST_BRAIN_ID.to_string(),
16694 seq: 1,
16695 commit_hash,
16696 feed_hash: "f".repeat(64),
16697 content_root: Some("a".repeat(64)),
16698 asset_root: Some("b".repeat(64)),
16699 materializer: "m".repeat(64),
16700 signer_epoch: 1,
16701 control_revision: "d".repeat(64),
16702 backup_preparation: "ready".to_string(),
16703 prior_pointer_hash: None,
16704 signed_at: "2026-08-23T00:00:00Z".to_string(),
16705 };
16706 let path = "assets/proof.bin".to_string();
16707 let asset = V2BaselineAsset {
16708 blob_sha256: sha256.clone(),
16709 bytes: bytes.len() as u64,
16710 media_type: "application/octet-stream".to_string(),
16711 wrappers: Vec::new(),
16712 required: true,
16713 disposition: "hosted".to_string(),
16714 leaf_hash: "e".repeat(64),
16715 };
16716
16717 let staged = stage_v2_asset_download_window(
16718 &cfg,
16719 TEST_BRAIN_ID,
16720 &pointer,
16721 &cache,
16722 &[(&path, &asset)],
16723 )
16724 .expect("a fresh authority-checked capability recovers an expired one");
16725 assert_eq!(staged.len(), 1);
16726 assert_eq!(staged[0].path, path);
16727 assert_eq!(std::fs::read(cache.join(sha256)).unwrap(), bytes);
16728 assert_eq!(object_attempt.load(std::sync::atomic::Ordering::SeqCst), 2);
16729 server.join().unwrap();
16730 }
16731
16732 #[cfg(any(unix, windows))]
16733 #[test]
16734 fn asset_capability_windows_cannot_exceed_the_worker_bound() {
16735 let temp = tempfile::tempdir().unwrap();
16736 let cfg = test_hub_config("http://127.0.0.1:1".to_string(), temp.path().to_path_buf());
16737 let pointer = V2PointerBody {
16738 v: 2,
16739 brain: TEST_BRAIN_ID.to_string(),
16740 seq: 1,
16741 commit_hash: "c".repeat(64),
16742 feed_hash: "f".repeat(64),
16743 content_root: Some("a".repeat(64)),
16744 asset_root: Some("b".repeat(64)),
16745 materializer: "m".repeat(64),
16746 signer_epoch: 1,
16747 control_revision: "d".repeat(64),
16748 backup_preparation: "ready".to_string(),
16749 prior_pointer_hash: None,
16750 signed_at: "2026-08-23T00:00:00Z".to_string(),
16751 };
16752 let paths = (0..=V2_DOWNLOAD_CAPABILITY_FILES)
16753 .map(|index| format!("assets/{index}.bin"))
16754 .collect::<Vec<_>>();
16755 let assets = paths
16756 .iter()
16757 .map(|_| V2BaselineAsset {
16758 blob_sha256: "a".repeat(64),
16759 bytes: 1,
16760 media_type: "application/octet-stream".to_string(),
16761 wrappers: Vec::new(),
16762 required: true,
16763 disposition: "hosted".to_string(),
16764 leaf_hash: "b".repeat(64),
16765 })
16766 .collect::<Vec<_>>();
16767 let pending = paths.iter().zip(&assets).collect::<Vec<_>>();
16768
16769 let error =
16770 stage_v2_asset_download_window(&cfg, TEST_BRAIN_ID, &pointer, temp.path(), &pending)
16771 .expect_err("an oversized window must fail before any network request");
16772 assert!(matches!(error, LinkError::InvalidFeed { .. }));
16773 }
16774
16775 #[test]
16776 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
16777 use ring::signature::KeyPair as _;
16778
16779 let rng = ring::rand::SystemRandom::new();
16780 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16781 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16782 let (spki, multikey) = public_identity_for(&pair);
16783 let key = AgentSigningKey {
16784 pkcs8: pkcs8.as_ref().to_vec(),
16785 multikey,
16786 public_key_spki: spki,
16787 };
16788 let header = linkmd_sig_header(
16789 &key,
16790 "https://hub-a.example",
16791 "post",
16792 "/api/hub/brains/brain/push?mode=exact",
16793 Some("{\"ok\":true}"),
16794 )
16795 .unwrap();
16796 assert!(header.starts_with("LinkMD-Sig v2,"));
16797 let ts = header
16798 .split(",ts=")
16799 .nth(1)
16800 .unwrap()
16801 .split(',')
16802 .next()
16803 .unwrap();
16804 let signature = URL_SAFE_NO_PAD
16805 .decode(header.rsplit(",sig=").next().unwrap())
16806 .unwrap();
16807 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
16808 let accepted = format!(
16809 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
16810 );
16811 let replayed = format!(
16812 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
16813 );
16814 let public = pair.public_key().as_ref();
16815 assert!(UnparsedPublicKey::new(&ED25519, public)
16816 .verify(accepted.as_bytes(), &signature)
16817 .is_ok());
16818 assert!(
16819 UnparsedPublicKey::new(&ED25519, public)
16820 .verify(replayed.as_bytes(), &signature)
16821 .is_err(),
16822 "a proof captured at hub A must not authenticate at hub B"
16823 );
16824 }
16825
16826 #[test]
16827 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
16828 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16829 let card = json!({
16830 "id": other,
16831 "headSeq": 0,
16832 "identity": signed_remote_fixture().identity,
16833 })
16834 .to_string();
16835 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
16836 let state = tempfile::tempdir().unwrap();
16837 let cfg = test_hub_config(hub, state.path().to_path_buf());
16838 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16839 assert!(
16840 error.contains("differs from the explicitly requested"),
16841 "{error}"
16842 );
16843 server.join().unwrap();
16844 }
16845
16846 #[test]
16847 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
16848 let first = signed_remote_fixture().identity;
16849 let second = signed_remote_fixture().identity;
16850 let card = |identity: FeedIdentity| {
16851 json!({
16852 "id": TEST_BRAIN_ID,
16853 "headSeq": 0,
16854 "identity": identity,
16855 })
16856 .to_string()
16857 };
16858 let (hub, server) = scripted_json_hub(vec![
16859 (404, "{}".to_string()),
16860 (200, card(first)),
16861 (404, "{}".to_string()),
16862 (200, card(second)),
16863 ]);
16864 let state = tempfile::tempdir().unwrap();
16865 let cfg = test_hub_config(hub, state.path().to_path_buf());
16866 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16867 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16868 assert!(
16869 error.contains("pinned anchor") || error.contains("forked away"),
16870 "{error}"
16871 );
16872 server.join().unwrap();
16873 }
16874
16875 #[test]
16876 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
16877 let old = signed_remote_fixture();
16878 let new = signed_remote_fixture();
16879 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
16880 let unsigned = serde_json::to_string(&UnsignedRotation {
16881 v: 1,
16882 op: "rotate",
16883 brain: &old.key.multikey,
16884 public_key: &old.key.public_key_spki,
16885 new_brain: &new.key.multikey,
16886 new_public_key: &new.key.public_key_spki,
16887 prior_head_seq: 1,
16888 prior_feed_hash: Some(&"a".repeat(64)),
16889 ts: "2026-07-30T12:00:00.000Z".to_string(),
16890 })
16891 .unwrap();
16892 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
16893 let rotation = format!(
16894 "{},\"sig\":\"{}\"}}",
16895 &unsigned[..unsigned.len() - 1],
16896 signature
16897 );
16898 let identity = FeedIdentity {
16899 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
16900 public_key_spki: new.key.public_key_spki,
16901 previous: vec![PreviousIdentity {
16902 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
16903 public_key_spki: old.key.public_key_spki,
16904 }],
16905 rotations: vec![rotation],
16906 };
16907 let card = json!({
16908 "id": TEST_BRAIN_ID,
16909 "headSeq": 0,
16910 "feedHash": null,
16911 "identity": identity,
16912 })
16913 .to_string();
16914 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
16915 let state = tempfile::tempdir().unwrap();
16916 let cfg = test_hub_config(hub, state.path().to_path_buf());
16917 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16918 assert!(
16919 error.contains("rotation claims a feed boundary beyond the advertised head"),
16920 "{error}"
16921 );
16922 assert!(
16923 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
16924 "an inconsistent empty-head identity must not become the TOFU checkpoint"
16925 );
16926 server.join().unwrap();
16927 }
16928
16929 #[test]
16930 fn trust_checkpoint_rejects_a_later_fork() {
16931 let fixture = signed_remote_fixture();
16932 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
16933 fork["feedHash"] = Value::String("b".repeat(64));
16934 let (hub, server) = scripted_json_hub(vec![
16935 (404, "{}".to_string()),
16936 (200, fixture.card),
16937 (200, fixture.feed),
16938 (404, "{}".to_string()),
16939 (200, fork.to_string()),
16940 ]);
16941 let state = tempfile::tempdir().unwrap();
16942 let cfg = test_hub_config(hub, state.path().to_path_buf());
16943 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16944 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
16945 server.join().unwrap();
16946 }
16947
16948 #[test]
16949 fn alias_and_canonical_id_share_one_identity_checkpoint() {
16950 let trusted = signed_remote_fixture();
16951 let attacker = signed_remote_fixture();
16952 let (hub, server) = scripted_json_hub(vec![
16953 (404, "{}".to_string()),
16954 (200, trusted.card),
16955 (200, trusted.feed),
16956 (404, "{}".to_string()),
16957 (200, attacker.card),
16958 ]);
16959 let state = tempfile::tempdir().unwrap();
16960 let cfg = test_hub_config(hub, state.path().to_path_buf());
16961 assert!(head(&cfg, "trusted-slug").unwrap().verified);
16962 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16963 assert!(
16964 error.contains("equivocation")
16965 || error.contains("pinned")
16966 || error.contains("identity"),
16967 "{error}"
16968 );
16969 server.join().unwrap();
16970 }
16971
16972 #[test]
16973 fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
16974 let state = tempfile::tempdir().unwrap();
16975 let cfg = test_hub_config(
16976 "https://hub.example".to_string(),
16977 state.path().to_path_buf(),
16978 );
16979 let directory = open_trust_dir(&cfg).unwrap();
16980 let old = TEST_BRAIN_ID;
16981 let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16982 save_alias_in(
16983 &cfg,
16984 &directory,
16985 &AliasBinding {
16986 v: 1,
16987 origin: normalized_origin(&cfg.hub).unwrap(),
16988 requested: "company-brain".to_string(),
16989 brain: old.to_string(),
16990 home: Some("company-brain".to_string()),
16991 },
16992 )
16993 .unwrap();
16994
16995 let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
16996 assert!(matches!(
16997 error,
16998 LinkError::AliasRebindRequired {
16999 alias,
17000 from,
17001 to
17002 } if alias == "company-brain" && from == old && to == new
17003 ));
17004 let unchanged = load_alias_in(&cfg, &directory, "company-brain")
17005 .unwrap()
17006 .unwrap();
17007 assert_eq!(unchanged.brain, old);
17008 assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
17009 }
17010
17011 #[test]
17012 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
17013 let alpha = signed_remote_fixture();
17014 let beta = signed_remote_fixture();
17015 let alpha_card = alpha.card.clone();
17016 let alpha_feed = alpha.feed.clone();
17017 let beta_card = beta.card.clone();
17018 let beta_feed = beta.feed.clone();
17019 let (hub, server) = routed_json_hub(5, move |path| {
17020 if path.ends_with("/v2/head") {
17021 (404, "{}".to_string())
17022 } else if path.contains("/alpha/feed?") {
17023 (200, alpha_feed.clone())
17024 } else if path.contains("/beta/feed?") {
17025 (200, beta_feed.clone())
17026 } else if path.ends_with("/alpha") {
17027 (200, alpha_card.clone())
17028 } else if path.ends_with("/beta") {
17029 (200, beta_card.clone())
17030 } else {
17031 (500, r#"{"error":"unexpected path"}"#.to_string())
17032 }
17033 });
17034 let state = tempfile::tempdir().unwrap();
17035 let cfg = test_hub_config(hub, state.path().to_path_buf());
17036 let alpha_cfg = cfg.clone();
17037 let beta_cfg = cfg;
17038 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
17039 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
17040 let results = [first.join().unwrap(), second.join().unwrap()];
17041 assert_eq!(
17042 results.iter().filter(|result| result.is_ok()).count(),
17043 1,
17044 "only one alias identity may establish canonical TOFU: {results:?}"
17045 );
17046 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
17047 server.join().unwrap();
17048 }
17049
17050 #[cfg(unix)]
17051 #[test]
17052 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
17053 use std::os::unix::fs::symlink;
17054
17055 let fixture = signed_remote_fixture();
17056 let card = json!({
17057 "id": TEST_BRAIN_ID,
17058 "headSeq": 0,
17059 "feedHash": Value::Null,
17060 "identity": fixture.identity,
17061 })
17062 .to_string();
17063 let work = tempfile::tempdir().unwrap();
17064 let outside = tempfile::tempdir().unwrap();
17065 let state = work.path().join("state");
17066 let moved = work.path().join("state-held");
17067 let swap_state = state.clone();
17068 let swap_moved = moved.clone();
17069 let outside_path = outside.path().to_path_buf();
17070 let (hub, server) = routed_json_hub(1, move |_| {
17071 std::fs::rename(&swap_state, &swap_moved).unwrap();
17073 symlink(&outside_path, &swap_state).unwrap();
17074 (200, card.clone())
17075 });
17076 let cfg = test_hub_config(hub, state);
17077
17078 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
17079 assert_eq!(verified.head.seq, 0);
17080 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
17081 assert!(std::fs::read_dir(moved.join("trust"))
17082 .unwrap()
17083 .flatten()
17084 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
17085 server.join().unwrap();
17086 }
17087
17088 #[test]
17089 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
17090 let remote = signed_remote_fixture();
17091 let unrelated = signed_remote_fixture().key;
17092 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
17093 let state = tempfile::tempdir().unwrap();
17094 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
17095 cfg.brain_key = Some(unrelated);
17096 let error = sync_push(
17097 &cfg,
17098 TEST_BRAIN_ID,
17099 &[("DB.md".to_string(), "signed local content".to_string())],
17100 )
17101 .unwrap_err()
17102 .to_string();
17103 assert!(
17104 error.contains("not the verified current brain identity"),
17105 "{error}"
17106 );
17107 server.join().unwrap();
17108 }
17109
17110 #[test]
17111 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
17112 let remote = signed_remote_fixture();
17113 let new = signed_remote_fixture().key;
17114 let state = tempfile::tempdir().unwrap();
17115 let new_file = state.path().join("new.key");
17116 std::fs::write(
17117 &new_file,
17118 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
17119 )
17120 .unwrap();
17121 #[cfg(unix)]
17122 {
17123 use std::os::unix::fs::PermissionsExt as _;
17124 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
17125 }
17126 let forged = json!({
17127 "brain": TEST_BRAIN_ID,
17128 "identity": {
17129 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
17130 "publicKeySpki": new.public_key_spki,
17131 }
17132 })
17133 .to_string();
17134 let (hub, server) = scripted_json_hub(vec![
17135 (404, "{}".to_string()),
17136 (200, remote.card.clone()),
17137 (200, remote.feed.clone()),
17138 (200, forged),
17139 (200, remote.card),
17140 (200, remote.feed),
17141 ]);
17142 let cfg = test_hub_config(hub, state.path().to_path_buf());
17143 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
17144 .unwrap_err()
17145 .to_string();
17146 assert!(
17147 error.contains("without committing the verified new identity"),
17148 "{error}"
17149 );
17150 server.join().unwrap();
17151 }
17152
17153 #[test]
17154 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
17155 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
17156 let raw = format!(
17157 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
17158 );
17159 let pack = build_store_pack(&[
17160 (
17161 "DB.md".to_string(),
17162 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
17163 ),
17164 ("records/clients/truth.md".to_string(), raw.clone()),
17165 ])
17166 .unwrap();
17167 let by_id = resolve_from_verified_pack(
17168 "01j5qc3v9k4ym8rwbn2tqe6f7d",
17169 &AddressTarget::Id(record_id.to_string()),
17170 pack.clone(),
17171 )
17172 .unwrap();
17173 assert_eq!(by_id["document"]["summary"], "Signed truth");
17174 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
17175 assert_eq!(
17176 by_id["document"]["contentSha"],
17177 content_sha256(raw.as_bytes())
17178 );
17179
17180 let by_path = resolve_from_verified_pack(
17181 "01j5qc3v9k4ym8rwbn2tqe6f7d",
17182 &AddressTarget::Path("records/clients/truth.md".to_string()),
17183 pack,
17184 )
17185 .unwrap();
17186 assert_eq!(by_path["document"]["id"], record_id);
17187 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
17188
17189 let wrong_id = resolve_from_verified_record_bytes(
17190 TEST_BRAIN_ID,
17191 &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
17192 "records/clients/truth.md".to_string(),
17193 raw.as_bytes().to_vec(),
17194 )
17195 .unwrap_err()
17196 .to_string();
17197 assert!(wrong_id.contains("id differs"), "{wrong_id}");
17198
17199 let wrong_path = resolve_from_verified_record_bytes(
17200 TEST_BRAIN_ID,
17201 &AddressTarget::Path("records/clients/other.md".to_string()),
17202 "records/clients/truth.md".to_string(),
17203 raw.into_bytes(),
17204 )
17205 .unwrap_err()
17206 .to_string();
17207 assert!(wrong_path.contains("path differs"), "{wrong_path}");
17208 }
17209
17210 #[test]
17211 fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
17212 let path = "records/clients/truth.md";
17213 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
17214 let raw = format!(
17215 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
17216 );
17217 let sha256 = content_sha256(raw.as_bytes());
17218 let mut nonce = 0_u128;
17219 let tree = crate::linkmd_v2::build_content_tree(
17220 &[crate::linkmd_v2::ContentFile {
17221 path: path.to_string(),
17222 blob_hash: sha256.clone(),
17223 bytes: raw.len() as u64,
17224 }],
17225 None,
17226 &mut || {
17227 nonce += 1;
17228 format!("{nonce:032x}")
17229 },
17230 )
17231 .unwrap();
17232 let root = tree.root.clone().unwrap();
17233 let mut directory_root = root.clone();
17234 let mut proof = Vec::new();
17235 for component in path.split('/') {
17236 let inclusion =
17237 crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
17238 let child = match &inclusion {
17239 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
17240 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
17241 panic!("fixture path must have an inclusion proof")
17242 }
17243 };
17244 proof.push(json!({
17245 "directory_root": directory_root,
17246 "component": component,
17247 "proof": inclusion,
17248 }));
17249 directory_root = child;
17250 }
17251 let commit_hash = "c".repeat(64);
17252 let pointer = V2PointerBody {
17253 v: 2,
17254 brain: TEST_BRAIN_ID.to_string(),
17255 seq: 1,
17256 commit_hash: commit_hash.clone(),
17257 feed_hash: "f".repeat(64),
17258 content_root: Some(root.clone()),
17259 asset_root: None,
17260 materializer: "dbmd-projection-v1".to_string(),
17261 signer_epoch: 1,
17262 control_revision: "d".repeat(64),
17263 backup_preparation: "e".repeat(64),
17264 prior_pointer_hash: None,
17265 signed_at: "2026-08-21T12:00:00.000Z".to_string(),
17266 };
17267 let manifest = json!({
17268 "v": 2,
17269 "commit": commit_hash,
17270 "content_root": root,
17271 "files": [{
17272 "path": path,
17273 "sha256": sha256,
17274 "bytes": raw.len(),
17275 "proof": proof,
17276 }],
17277 "next_cursor": Value::Null,
17278 })
17279 .to_string();
17280
17281 let path_manifest = manifest.clone();
17282 let (hub, server) = routed_json_hub(1, move |request| {
17283 assert_eq!(
17284 request,
17285 format!(
17286 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
17287 "c".repeat(64)
17288 )
17289 );
17290 (200, path_manifest.clone())
17291 });
17292 let state = tempfile::tempdir().unwrap();
17293 let cfg = test_hub_config(hub, state.path().to_path_buf());
17294 let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
17295 .unwrap()
17296 .unwrap();
17297 assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
17298 assert!(by_path.proof.is_some());
17299 server.join().unwrap();
17300
17301 let (hub, server) = routed_json_hub(1, move |request| {
17302 assert_eq!(
17303 request,
17304 format!(
17305 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
17306 "c".repeat(64)
17307 )
17308 );
17309 (404, r#"{"error":"File not found"}"#.to_string())
17310 });
17311 let state = tempfile::tempdir().unwrap();
17312 let cfg = test_hub_config(hub, state.path().to_path_buf());
17313 assert!(
17314 v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
17315 .unwrap()
17316 .is_none()
17317 );
17318 server.join().unwrap();
17319
17320 let id_manifest = manifest;
17321 let (hub, server) = routed_json_hub(1, move |request| {
17322 assert_eq!(
17323 request,
17324 format!(
17325 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
17326 "c".repeat(64)
17327 )
17328 );
17329 (200, id_manifest.clone())
17330 });
17331 let state = tempfile::tempdir().unwrap();
17332 let cfg = test_hub_config(hub, state.path().to_path_buf());
17333 let (located_path, by_id) =
17334 v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
17335 assert_eq!(located_path, path);
17336 assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
17337 server.join().unwrap();
17338 }
17339
17340 #[test]
17341 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
17342 let unsorted = vec![
17343 ("records/a.md".to_string(), "alpha\n".to_string()),
17344 ("DB.md".to_string(), "# db\n".to_string()),
17345 ];
17346 let sorted = vec![
17347 ("DB.md".to_string(), "# db\n".to_string()),
17348 ("records/a.md".to_string(), "alpha\n".to_string()),
17349 ];
17350 let pack = build_store_pack(&unsorted).unwrap();
17351
17352 assert_eq!(pack.len(), 219);
17357 assert_eq!(
17358 content_sha256(&pack),
17359 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
17360 );
17361 assert_eq!(pack, build_store_pack(&sorted).unwrap());
17362 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
17363 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
17364 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
17365
17366 assert_eq!(
17367 parse_store_pack(pack).unwrap(),
17368 vec![
17369 ("DB.md".to_string(), b"# db\n".to_vec()),
17370 ("records/a.md".to_string(), b"alpha\n".to_vec()),
17371 ]
17372 );
17373 }
17374
17375 #[test]
17376 fn canonical_store_pack_validates_every_path_before_writing() {
17377 let duplicate = vec![
17378 ("DB.md".to_string(), "first".to_string()),
17379 ("DB.md".to_string(), "second".to_string()),
17380 ];
17381 assert!(build_store_pack(&duplicate)
17382 .unwrap_err()
17383 .to_string()
17384 .contains("duplicate path"));
17385 assert!(matches!(
17386 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
17387 Err(LinkError::UnsafePath { .. })
17388 ));
17389 }
17390
17391 #[test]
17392 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
17393 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
17394 let mut bytes = vec![0_u8];
17397 let zip64_offset = bytes.len() as u64;
17398 bytes.extend_from_slice(b"PK\x06\x06");
17399 bytes.extend_from_slice(&44_u64.to_le_bytes());
17400 bytes.extend_from_slice(&[0_u8; 12]);
17401 bytes.extend_from_slice(&COUNT.to_le_bytes());
17402 bytes.extend_from_slice(&COUNT.to_le_bytes());
17403 bytes.extend_from_slice(&1_u64.to_le_bytes());
17404 bytes.extend_from_slice(&0_u64.to_le_bytes());
17405 bytes.extend_from_slice(b"PK\x06\x07");
17406 bytes.extend_from_slice(&0_u32.to_le_bytes());
17407 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
17408 bytes.extend_from_slice(&1_u32.to_le_bytes());
17409 bytes.extend_from_slice(b"PK\x05\x06");
17410 bytes.extend_from_slice(&0_u16.to_le_bytes());
17411 bytes.extend_from_slice(&0_u16.to_le_bytes());
17412 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17413 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17414 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17415 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17416 bytes.extend_from_slice(&0_u16.to_le_bytes());
17417
17418 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
17419 .unwrap_err()
17420 .to_string();
17421 assert!(error.contains("invalid file count"), "{error}");
17422 }
17423
17424 #[test]
17425 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
17426 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
17427 let mut bytes = vec![0_u8];
17428 let zip64_offset = bytes.len() as u64;
17429 bytes.extend_from_slice(b"PK\x06\x06");
17430 bytes.extend_from_slice(&44_u64.to_le_bytes());
17431 bytes.extend_from_slice(&[0_u8; 12]);
17432 bytes.extend_from_slice(&COUNT.to_le_bytes());
17433 bytes.extend_from_slice(&COUNT.to_le_bytes());
17434 bytes.extend_from_slice(&1_u64.to_le_bytes());
17435 bytes.extend_from_slice(&0_u64.to_le_bytes());
17436 bytes.extend_from_slice(b"PK\x06\x07");
17437 bytes.extend_from_slice(&0_u32.to_le_bytes());
17438 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
17439 bytes.extend_from_slice(&1_u32.to_le_bytes());
17440 bytes.extend_from_slice(b"PK\x05\x06");
17441 bytes.extend_from_slice(&0_u16.to_le_bytes());
17442 bytes.extend_from_slice(&0_u16.to_le_bytes());
17443 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17444 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17445 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17446 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17447 bytes.extend_from_slice(&0_u16.to_le_bytes());
17448 let fake_eocd = bytes.len() as u32;
17452 bytes.extend_from_slice(b"PK\x05\x06");
17453 bytes.extend_from_slice(&0_u16.to_le_bytes());
17454 bytes.extend_from_slice(&0_u16.to_le_bytes());
17455 bytes.extend_from_slice(&1_u16.to_le_bytes());
17456 bytes.extend_from_slice(&1_u16.to_le_bytes());
17457 bytes.extend_from_slice(&0_u32.to_le_bytes());
17458 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
17459 bytes.extend_from_slice(&0_u16.to_le_bytes());
17460
17461 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
17462 .unwrap_err()
17463 .to_string();
17464 assert!(error.contains("central directory"), "{error}");
17465 }
17466
17467 #[test]
17468 fn strict_http_status_handling_rejects_redirects_without_panicking() {
17469 let error = ensure_ok(
17470 HubResponse {
17471 status: 302,
17472 body: Some(json!({"redirect": "/elsewhere"})),
17473 },
17474 "mutation",
17475 )
17476 .unwrap_err();
17477 assert!(matches!(error, LinkError::Http { status: 302, .. }));
17478
17479 let error = ensure_raw_ok(
17480 RawHubResponse {
17481 status: 302,
17482 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
17483 },
17484 "feed",
17485 )
17486 .unwrap_err();
17487 assert!(matches!(error, LinkError::Http { status: 302, .. }));
17488 }
17489
17490 #[cfg(unix)]
17491 #[test]
17492 fn collect_push_files_refuses_external_symlink_and_nested_store() {
17493 use std::os::unix::fs::symlink;
17494
17495 let root = tempfile::tempdir().unwrap();
17496 std::fs::write(
17497 root.path().join("DB.md"),
17498 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
17499 )
17500 .unwrap();
17501 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
17502
17503 let external = tempfile::tempdir().unwrap();
17504 let secret = external.path().join("secret.md");
17505 std::fs::write(&secret, "TOP SECRET").unwrap();
17506 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
17507
17508 let store = Store::open_strict(root.path()).unwrap();
17509 let err = collect_push_files(&store).unwrap_err().to_string();
17510 assert!(err.contains("cannot push"), "{err}");
17511 assert!(
17512 !err.contains("TOP SECRET"),
17513 "external bytes must never leak"
17514 );
17515
17516 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
17517 let nested = root.path().join("records/nested");
17518 std::fs::create_dir_all(&nested).unwrap();
17519 std::fs::write(
17520 nested.join("DB.md"),
17521 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
17522 )
17523 .unwrap();
17524 let err = collect_push_files(&store).unwrap_err().to_string();
17525 assert!(err.contains("nested db.md store"), "{err}");
17526 }
17527
17528 #[test]
17529 fn collect_push_files_carries_curator_history_but_not_derived_catalogs() {
17530 let root = tempfile::tempdir().unwrap();
17531 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
17532 std::fs::create_dir_all(root.path().join("log")).unwrap();
17533 std::fs::write(
17534 root.path().join("DB.md"),
17535 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
17536 )
17537 .unwrap();
17538 std::fs::write(root.path().join("index.md"), "derived root catalog\n").unwrap();
17539 std::fs::write(
17540 root.path().join("records/notes/index.md"),
17541 "derived type catalog\n",
17542 )
17543 .unwrap();
17544 std::fs::write(
17545 root.path().join("records/notes/owned.md"),
17546 "---\ntype: note\nsummary: owned\ncreated: 2026-08-26T00:00:00Z\nupdated: 2026-08-26T00:00:00Z\n---\n",
17547 )
17548 .unwrap();
17549 std::fs::write(
17550 root.path().join("log.md"),
17551 "---\ntype: log\n---\n\n# Curator log\n",
17552 )
17553 .unwrap();
17554 std::fs::write(
17555 root.path().join("log/2026-07.md"),
17556 "---\ntype: log\n---\n\n# Curator log — 2026-07\n",
17557 )
17558 .unwrap();
17559 std::fs::write(root.path().join("log/README.txt"), "not a log archive\n").unwrap();
17560
17561 let store = Store::open_strict(root.path()).unwrap();
17562 let paths: Vec<String> = collect_push_files(&store)
17563 .unwrap()
17564 .into_iter()
17565 .map(|(path, _)| path)
17566 .collect();
17567
17568 assert!(paths.contains(&"DB.md".to_string()));
17569 assert!(paths.contains(&"records/notes/owned.md".to_string()));
17570 assert!(paths.contains(&"log.md".to_string()));
17571 assert!(paths.contains(&"log/2026-07.md".to_string()));
17572 assert!(!paths.contains(&"index.md".to_string()));
17573 assert!(!paths.contains(&"records/notes/index.md".to_string()));
17574 assert!(!paths.contains(&"log/README.txt".to_string()));
17575 }
17576
17577 #[cfg(unix)]
17578 #[test]
17579 fn remote_push_uses_opened_root_after_path_replacement() {
17580 use std::os::unix::fs::symlink;
17581
17582 let sandbox = tempfile::tempdir().unwrap();
17583 let root = sandbox.path().join("store");
17584 std::fs::create_dir_all(root.join("records/notes")).unwrap();
17585 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17586 std::fs::write(
17587 root.join("records/notes/owned.md"),
17588 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
17589 )
17590 .unwrap();
17591 let store = Store::open_strict(&root).unwrap();
17592 let detached = sandbox.path().join("detached");
17593 std::fs::rename(&root, &detached).unwrap();
17594
17595 let replacement = sandbox.path().join("replacement");
17596 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
17597 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17598 std::fs::write(
17599 replacement.join("records/notes/secret.md"),
17600 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
17601 )
17602 .unwrap();
17603 symlink(&replacement, &root).unwrap();
17604
17605 let files = collect_push_files(&store).unwrap();
17606 let wire_text = files
17607 .iter()
17608 .map(|(path, content)| format!("{path}\n{content}"))
17609 .collect::<Vec<_>>()
17610 .join("\n");
17611 assert!(wire_text.contains("owned upload"));
17612 assert!(!wire_text.contains("replacement sentinel"));
17613 assert!(!wire_text.contains("records/notes/secret.md"));
17614
17615 let remote = signed_remote_fixture();
17616 let (hub, server) = scripted_json_hub(vec![
17617 (200, remote.card),
17618 (200, remote.feed),
17619 (200, json!({"ok": true}).to_string()),
17620 ]);
17621 let state = tempfile::tempdir().unwrap();
17622 let cfg = test_hub_config(hub, state.path().to_path_buf());
17623 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
17624 assert_eq!(pushed, json!({"ok": true}));
17625 server.join().unwrap();
17626 }
17627
17628 #[test]
17629 fn signed_feed_item_verifies_identity_hash_and_signature() {
17630 use ring::rand::SystemRandom;
17631 use ring::signature::{Ed25519KeyPair, KeyPair};
17632
17633 const PREFIX: &[u8] = &[
17634 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
17635 ];
17636 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
17637 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17638 let mut spki = PREFIX.to_vec();
17639 spki.extend_from_slice(pair.public_key().as_ref());
17640 let public_key = URL_SAFE_NO_PAD.encode(&spki);
17641 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
17642 let mut entry = FeedEntry {
17643 v: 1,
17644 seq: 1,
17645 ts: "2026-07-14T00:00:00.000Z".to_string(),
17646 brain: format!("ed25519:{fingerprint}"),
17647 public_key: public_key.clone(),
17648 kind: "push".to_string(),
17649 op: "snapshot".to_string(),
17650 pack_sha256: "a".repeat(64),
17651 files: vec![FeedFile {
17652 path: "DB.md".to_string(),
17653 sha256: "b".repeat(64),
17654 bytes: 3,
17655 }],
17656 removed: vec![],
17657 prev_entry_hash: None,
17658 sig: String::new(),
17659 };
17660 let unsigned = UnsignedFeedEntry {
17661 v: entry.v,
17662 seq: entry.seq,
17663 ts: &entry.ts,
17664 brain: &entry.brain,
17665 public_key: &entry.public_key,
17666 kind: &entry.kind,
17667 op: &entry.op,
17668 pack_sha256: &entry.pack_sha256,
17669 files: &entry.files,
17670 removed: &entry.removed,
17671 prev_entry_hash: &entry.prev_entry_hash,
17672 };
17673 entry.sig =
17674 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
17675 let mut exact = serde_json::to_vec(&entry).unwrap();
17676 exact.push(b'\n');
17677 let item = FeedItem {
17678 hash: format!("{:x}", Sha256::digest(&exact)),
17679 entry,
17680 };
17681 let identity = FeedIdentity {
17682 fingerprint,
17683 public_key_spki: public_key,
17684 previous: Vec::new(),
17685 rotations: Vec::new(),
17686 };
17687 assert!(verify_feed_item(&item, &identity).is_ok());
17688 let mut tampered = item;
17689 tampered.entry.pack_sha256 = "c".repeat(64);
17690 assert!(verify_feed_item(&tampered, &identity).is_err());
17691 }
17692
17693 #[test]
17694 fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
17695 let rng = ring::rand::SystemRandom::new();
17696 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17697 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17698 let (spki, multikey) = public_identity_for(&pair);
17699 let identity = V2HeadIdentity {
17700 custody: "self".to_string(),
17701 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
17702 public_key_spki: spki.clone(),
17703 previous: Vec::new(),
17704 rotations: Vec::new(),
17705 };
17706 let unsigned = json!({
17707 "actor_ref": "a".repeat(64),
17708 "asset_root": Value::Null,
17709 "brain": multikey,
17710 "changes_sha256": "b".repeat(64),
17711 "control_revision": "c".repeat(64),
17712 "materializer": "dbmd-projection-v1",
17713 "op": "changeset",
17714 "parent_asset_root": Value::Null,
17715 "parent_commit": Value::Null,
17716 "parent_root": Value::Null,
17717 "prev_entry_hash": Value::Null,
17718 "public_key": spki,
17719 "seq": 1,
17720 "signer_epoch": 1,
17721 "state_root": "d".repeat(64),
17722 "ts": "2026-08-19T12:00:00.000Z",
17723 "v": 2,
17724 "v1_bridge": {
17725 "feed_hash": "e".repeat(64),
17726 "head_seq": 7,
17727 "pack_sha256": "f".repeat(64),
17728 },
17729 });
17730 let sign_value = |value: Value| {
17731 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
17732 let mut object = value.as_object().unwrap().clone();
17733 object.insert(
17734 "sig".to_string(),
17735 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17736 );
17737 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
17738 };
17739 assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
17740
17741 let mut extra = unsigned.clone();
17742 extra
17743 .as_object_mut()
17744 .unwrap()
17745 .insert("future".to_string(), Value::Bool(true));
17746 assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
17747
17748 let mut missing = unsigned.clone();
17749 missing.as_object_mut().unwrap().remove("v1_bridge");
17750 assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
17751
17752 let mut invalid_bridge = unsigned;
17753 invalid_bridge.as_object_mut().unwrap().insert(
17754 "v1_bridge".to_string(),
17755 json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
17756 );
17757 assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
17758 }
17759
17760 #[test]
17761 fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
17762 let vector: Value = serde_json::from_str(include_str!(
17763 "../tests/vectors/linkmd-v2-commit-bridge.json"
17764 ))
17765 .unwrap();
17766 let identity_value = vector.get("identity").unwrap();
17767 let identity = V2HeadIdentity {
17768 custody: "self".to_string(),
17769 fingerprint: identity_value
17770 .get("fingerprint")
17771 .and_then(Value::as_str)
17772 .unwrap()
17773 .to_string(),
17774 public_key_spki: identity_value
17775 .get("public_key_spki")
17776 .and_then(Value::as_str)
17777 .unwrap()
17778 .to_string(),
17779 previous: Vec::new(),
17780 rotations: Vec::new(),
17781 };
17782 let private = URL_SAFE_NO_PAD
17783 .decode(
17784 identity_value
17785 .get("private_key_pkcs8")
17786 .and_then(Value::as_str)
17787 .unwrap(),
17788 )
17789 .unwrap();
17790 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
17791 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
17792 .unwrap();
17793 let base = vector.get("body").unwrap().as_object().unwrap();
17794
17795 for item in vector.get("valid").unwrap().as_array().unwrap() {
17796 let mut body = base.clone();
17797 body.insert(
17798 "v1_bridge".to_string(),
17799 item.get("v1_bridge").unwrap().clone(),
17800 );
17801 body.insert(
17802 "sig".to_string(),
17803 item.get("signature_base64url").unwrap().clone(),
17804 );
17805 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
17806 assert!(verified_v2_commit_object(&signed, &identity).is_ok());
17807 assert_eq!(
17808 crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
17809 item.get("commit_hash").and_then(Value::as_str).unwrap()
17810 );
17811 assert_eq!(
17812 format!("{:x}", Sha256::digest(&signed)),
17813 item.get("feed_hash").and_then(Value::as_str).unwrap()
17814 );
17815 }
17816
17817 for item in vector.get("invalid").unwrap().as_array().unwrap() {
17818 let mut body = base.clone();
17819 if let Some(remove) = item.get("remove").and_then(Value::as_array) {
17820 for field in remove {
17821 body.remove(field.as_str().unwrap());
17822 }
17823 }
17824 if let Some(set) = item.get("set").and_then(Value::as_object) {
17825 for (field, value) in set {
17826 body.insert(field.clone(), value.clone());
17827 }
17828 }
17829 let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
17830 body.insert(
17831 "sig".to_string(),
17832 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17833 );
17834 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
17835 assert!(
17836 verified_v2_commit_object(&signed, &identity).is_err(),
17837 "accepted invalid shared vector {}",
17838 item.get("reason").and_then(Value::as_str).unwrap()
17839 );
17840 }
17841 }
17842
17843 #[test]
17844 fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
17845 let vector: Value = serde_json::from_str(include_str!(
17846 "../tests/vectors/linkmd-v2-changeset-withheld.json"
17847 ))
17848 .unwrap();
17849 assert_eq!(
17850 vector.get("profile").and_then(Value::as_str),
17851 Some("link.md-v2-changeset-withheld")
17852 );
17853 let canonical =
17854 crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
17855 let expected = STANDARD
17856 .decode(
17857 vector
17858 .get("canonical_base64")
17859 .and_then(Value::as_str)
17860 .unwrap(),
17861 )
17862 .unwrap();
17863 assert_eq!(canonical, expected);
17864 assert_eq!(
17865 crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
17866 vector.get("domain_hash").and_then(Value::as_str).unwrap()
17867 );
17868 }
17869
17870 #[test]
17871 fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
17872 let remote = signed_remote_fixture();
17873 let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
17874 let legacy_item = legacy.entries.first().unwrap();
17875 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
17876 let body = json!({
17877 "actor_ref": "a".repeat(64),
17878 "asset_root": Value::Null,
17879 "brain": remote.key.multikey,
17880 "changes_sha256": "b".repeat(64),
17881 "control_revision": "c".repeat(64),
17882 "materializer": "dbmd-projection-v1",
17883 "op": "changeset",
17884 "parent_asset_root": Value::Null,
17885 "parent_commit": Value::Null,
17886 "parent_root": Value::Null,
17887 "prev_entry_hash": Value::Null,
17888 "public_key": remote.key.public_key_spki,
17889 "seq": 1,
17890 "signer_epoch": 1,
17891 "state_root": "d".repeat(64),
17892 "ts": "2026-08-19T12:00:00.000Z",
17893 "v": 2,
17894 "v1_bridge": {
17895 "feed_hash": legacy_item.hash,
17896 "head_seq": legacy_item.entry.seq,
17897 "pack_sha256": legacy_item.entry.pack_sha256,
17898 },
17899 });
17900 let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
17901 let mut signed = body.as_object().unwrap().clone();
17902 signed.insert(
17903 "sig".to_string(),
17904 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17905 );
17906 let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
17907 let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
17908 let feed_hash = content_sha256(&raw);
17909 let pointer = V2PointerBody {
17910 v: 2,
17911 brain: TEST_BRAIN_ID.to_string(),
17912 seq: 1,
17913 commit_hash: commit_hash.clone(),
17914 feed_hash: feed_hash.clone(),
17915 content_root: Some("d".repeat(64)),
17916 asset_root: None,
17917 materializer: "dbmd-projection-v1".to_string(),
17918 signer_epoch: 1,
17919 control_revision: "c".repeat(64),
17920 backup_preparation: "e".repeat(64),
17921 prior_pointer_hash: None,
17922 signed_at: "2026-08-19T12:00:00.000Z".to_string(),
17923 };
17924 let v2_page = json!({
17925 "v": 2,
17926 "head_seq": 1,
17927 "head_commit_hash": commit_hash,
17928 "head_feed_hash": feed_hash,
17929 "entries": [{
17930 "seq": 1,
17931 "commit_hash": pointer.commit_hash,
17932 "feed_hash": pointer.feed_hash,
17933 "bytes_base64": STANDARD.encode(&raw),
17934 }],
17935 "next_after": 1,
17936 "complete": true,
17937 })
17938 .to_string();
17939 let identity = V2HeadIdentity {
17940 custody: "self".to_string(),
17941 fingerprint: remote.identity.fingerprint.clone(),
17942 public_key_spki: remote.identity.public_key_spki.clone(),
17943 previous: Vec::new(),
17944 rotations: Vec::new(),
17945 };
17946 let checkpoint = TrustState {
17947 v: 2,
17948 origin: "unused".to_string(),
17949 requested: TEST_BRAIN_ID.to_string(),
17950 brain: TEST_BRAIN_ID.to_string(),
17951 home: None,
17952 anchor: remote.key.multikey.clone(),
17953 current: remote.key.multikey,
17954 head_seq: legacy_item.entry.seq,
17955 feed_hash: Some(legacy_item.hash.clone()),
17956 rotations: Vec::new(),
17957 hub_signer: None,
17958 protocol_profile: None,
17959 };
17960 let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
17961 let state = tempfile::tempdir().unwrap();
17962 let cfg = test_hub_config(hub, state.path().to_path_buf());
17963 verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
17964 server.join().unwrap();
17965
17966 let mut wrong = checkpoint;
17967 wrong.feed_hash = Some("0".repeat(64));
17968 let (hub, server) = scripted_json_hub(vec![(
17969 200,
17970 json!({
17971 "v": 2,
17972 "head_seq": 1,
17973 "head_commit_hash": pointer.commit_hash,
17974 "head_feed_hash": pointer.feed_hash,
17975 "entries": [{
17976 "seq": 1,
17977 "commit_hash": pointer.commit_hash,
17978 "feed_hash": pointer.feed_hash,
17979 "bytes_base64": STANDARD.encode(&raw),
17980 }],
17981 "next_after": 1,
17982 "complete": true,
17983 })
17984 .to_string(),
17985 )]);
17986 let state = tempfile::tempdir().unwrap();
17987 let cfg = test_hub_config(hub, state.path().to_path_buf());
17988 assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
17989 server.join().unwrap();
17990 }
17991
17992 #[test]
17993 fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
17994 let rng = ring::rand::SystemRandom::new();
17995 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17996 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17997 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17998 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17999 let (old_spki, old_multikey) = public_identity_for(&old);
18000 let (new_spki, new_multikey) = public_identity_for(&new);
18001 let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
18002 v: 1,
18003 op: "rotate",
18004 brain: &old_multikey,
18005 public_key: &old_spki,
18006 new_brain: &new_multikey,
18007 new_public_key: &new_spki,
18008 prior_head_seq: 1,
18009 prior_feed_hash: Some(&"9".repeat(64)),
18010 ts: "2026-08-19T12:01:00.000Z".to_string(),
18011 })
18012 .unwrap();
18013 let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
18014 let rotation = format!(
18015 "{},\"sig\":\"{}\"}}",
18016 &rotation_unsigned[..rotation_unsigned.len() - 1],
18017 rotation_sig
18018 );
18019 let identity = V2HeadIdentity {
18020 custody: "self".to_string(),
18021 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
18022 public_key_spki: new_spki.clone(),
18023 previous: vec![V2PreviousIdentity {
18024 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
18025 public_key_spki: old_spki.clone(),
18026 }],
18027 rotations: vec![rotation],
18028 };
18029 let commit = |seq: u64,
18030 epoch: u64,
18031 multikey: &str,
18032 spki: &str,
18033 pair: &ring::signature::Ed25519KeyPair| {
18034 let value = json!({
18035 "actor_ref": "a".repeat(64),
18036 "asset_root": Value::Null,
18037 "brain": multikey,
18038 "changes_sha256": "b".repeat(64),
18039 "control_revision": "c".repeat(64),
18040 "materializer": "dbmd-projection-v1",
18041 "op": "changeset",
18042 "parent_asset_root": Value::Null,
18043 "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
18044 "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
18045 "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
18046 "public_key": spki,
18047 "seq": seq,
18048 "signer_epoch": epoch,
18049 "state_root": "1".repeat(64),
18050 "ts": "2026-08-19T12:00:00.000Z",
18051 "v": 2,
18052 "v1_bridge": Value::Null,
18053 });
18054 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
18055 let mut object = value.as_object().unwrap().clone();
18056 object.insert(
18057 "sig".to_string(),
18058 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
18059 );
18060 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
18061 };
18062
18063 assert!(verified_v2_commit_object(
18064 &commit(1, 1, &old_multikey, &old_spki, &old),
18065 &identity,
18066 )
18067 .is_ok());
18068 assert!(verified_v2_commit_object(
18069 &commit(2, 2, &new_multikey, &new_spki, &new),
18070 &identity,
18071 )
18072 .is_ok());
18073 assert!(verified_v2_commit_object(
18074 &commit(2, 1, &old_multikey, &old_spki, &old),
18075 &identity,
18076 )
18077 .is_err());
18078 assert!(verified_v2_commit_object(
18079 &commit(1, 2, &new_multikey, &new_spki, &new),
18080 &identity,
18081 )
18082 .is_err());
18083 }
18084
18085 #[test]
18086 fn a_self_custody_entry_verifies_like_any_hub_entry() {
18087 let rng = ring::rand::SystemRandom::new();
18088 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18089 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18090 let (spki, multikey) = public_identity_for(&pair);
18091 let key = AgentSigningKey {
18092 pkcs8: pkcs8.as_ref().to_vec(),
18093 multikey: multikey.clone(),
18094 public_key_spki: spki.clone(),
18095 };
18096 let files = vec![WireFeedFile {
18097 path: "DB.md".to_string(),
18098 sha256: "a".repeat(64),
18099 bytes: 3,
18100 }];
18101 let raw = self_custody_entry(
18102 &key,
18103 1,
18104 "2026-07-23T12:00:00.000Z".to_string(),
18105 &"c".repeat(64),
18106 &files,
18107 None,
18108 )
18109 .unwrap();
18110 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
18114 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
18115 let item = FeedItem { hash, entry };
18116 let identity = FeedIdentity {
18117 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
18118 public_key_spki: spki,
18119 previous: Vec::new(),
18120 rotations: Vec::new(),
18121 };
18122 assert!(verify_feed_item(&item, &identity).is_ok());
18123 }
18124
18125 #[test]
18126 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
18127 let rng = ring::rand::SystemRandom::new();
18128 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18129 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
18130 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18131 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
18132 let (old_spki, old_multikey) = public_identity_for(&old);
18133 let (new_spki, new_multikey) = public_identity_for(&new);
18134 let unsigned = serde_json::to_string(&UnsignedRotation {
18135 v: 1,
18136 op: "rotate",
18137 brain: &old_multikey,
18138 public_key: &old_spki,
18139 new_brain: &new_multikey,
18140 new_public_key: &new_spki,
18141 prior_head_seq: 1,
18142 prior_feed_hash: Some(&"a".repeat(64)),
18143 ts: "2026-07-30T12:00:00.000Z".to_string(),
18144 })
18145 .unwrap();
18146 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
18147 let rotation = format!(
18148 "{},\"sig\":\"{}\"}}",
18149 &unsigned[..unsigned.len() - 1],
18150 signature
18151 );
18152 let identity = FeedIdentity {
18153 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
18154 public_key_spki: new_spki,
18155 previous: vec![PreviousIdentity {
18156 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
18157 public_key_spki: old_spki,
18158 }],
18159 rotations: vec![rotation],
18160 };
18161 let pin = TrustState {
18162 v: 2,
18163 origin: "https://hub.example".to_string(),
18164 requested: "brain".to_string(),
18165 brain: "brain".to_string(),
18166 home: None,
18167 anchor: old_multikey.clone(),
18168 current: old_multikey.clone(),
18169 head_seq: 1,
18170 feed_hash: Some("a".repeat(64)),
18171 rotations: Vec::new(),
18172 hub_signer: None,
18173 protocol_profile: None,
18174 };
18175 assert_eq!(
18176 verify_identity_chain(&identity, Some(&pin)).unwrap(),
18177 old_multikey
18178 );
18179 let mut accepted = pin.clone();
18180 accepted.current = new_multikey.clone();
18181 accepted.rotations = identity.rotations.clone();
18182 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
18183 v: 1,
18184 op: "rotate",
18185 brain: &old_multikey,
18186 public_key: &identity.previous[0].public_key_spki,
18187 new_brain: &new_multikey,
18188 new_public_key: &identity.public_key_spki,
18189 prior_head_seq: 1,
18190 prior_feed_hash: Some(&"a".repeat(64)),
18191 ts: "2026-07-30T12:00:01.000Z".to_string(),
18192 })
18193 .unwrap();
18194 let alternate_signature =
18195 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
18196 let mut rewritten = identity.clone();
18197 rewritten.rotations[0] = format!(
18198 "{},\"sig\":\"{}\"}}",
18199 &alternate_unsigned[..alternate_unsigned.len() - 1],
18200 alternate_signature
18201 );
18202 assert!(
18203 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
18204 "an alternate valid statement must not rewrite accepted history"
18205 );
18206
18207 let mut stale_entry = FeedEntry {
18208 v: 1,
18209 seq: 2,
18210 ts: "2026-07-30T12:01:00.000Z".to_string(),
18211 brain: pin.current.clone(),
18212 public_key: identity.previous[0].public_key_spki.clone(),
18213 kind: "push".to_string(),
18214 op: "snapshot".to_string(),
18215 pack_sha256: "b".repeat(64),
18216 files: Vec::new(),
18217 removed: Vec::new(),
18218 prev_entry_hash: pin.feed_hash.clone(),
18219 sig: String::new(),
18220 };
18221 let stale_unsigned = UnsignedFeedEntry {
18222 v: stale_entry.v,
18223 seq: stale_entry.seq,
18224 ts: &stale_entry.ts,
18225 brain: &stale_entry.brain,
18226 public_key: &stale_entry.public_key,
18227 kind: &stale_entry.kind,
18228 op: &stale_entry.op,
18229 pack_sha256: &stale_entry.pack_sha256,
18230 files: &stale_entry.files,
18231 removed: &stale_entry.removed,
18232 prev_entry_hash: &stale_entry.prev_entry_hash,
18233 };
18234 stale_entry.sig = URL_SAFE_NO_PAD.encode(
18235 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
18236 .as_ref(),
18237 );
18238 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
18239 stale_exact.push(b'\n');
18240 let stale_item = FeedItem {
18241 hash: content_sha256(&stale_exact),
18242 entry: stale_entry,
18243 };
18244 assert!(
18245 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
18246 .is_err(),
18247 "a key retired before the checkpoint must never append after it"
18248 );
18249 assert!(
18250 verify_feed_item(&stale_item, &identity).is_err(),
18251 "an old key must never append after its signed rotation boundary"
18252 );
18253
18254 let mut missing = identity.clone();
18255 missing.rotations.clear();
18256 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
18257
18258 let mut tampered = identity;
18259 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
18260 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
18261 }
18262
18263 #[cfg(unix)]
18264 #[test]
18265 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
18266 use std::os::unix::fs::symlink;
18267
18268 let dir = tempfile::tempdir().unwrap();
18269 let target = dir.path().join("valuable.txt");
18270 let planted = dir.path().join("agent.key");
18271 std::fs::write(&target, "do not overwrite").unwrap();
18272 symlink(&target, &planted).unwrap();
18273
18274 assert!(matches!(
18275 generate_agent_key(&planted),
18276 Err(LinkError::BadAgentKey { .. })
18277 ));
18278 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
18279 }
18280
18281 #[cfg(unix)]
18282 #[test]
18283 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
18284 use std::os::unix::fs::symlink;
18285
18286 let root = tempfile::tempdir().unwrap();
18287 let outside = tempfile::tempdir().unwrap();
18288 symlink(outside.path(), root.path().join("redirect")).unwrap();
18289
18290 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
18291 assert!(!outside.path().join("agent.key").exists());
18292 }
18293
18294 #[test]
18297 fn address_bare_brain_with_and_without_sigil() {
18298 for raw in ["@acme-ops", "acme-ops"] {
18299 let a = Address::parse(raw).expect(raw);
18300 assert_eq!(a.brain, "acme-ops");
18301 assert_eq!(a.target, None);
18302 }
18303 }
18304
18305 #[test]
18306 fn address_ulid_target_parses_as_id() {
18307 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
18308 assert_eq!(a.brain, "acme");
18309 assert_eq!(
18310 a.target,
18311 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
18312 );
18313 }
18314
18315 #[test]
18316 fn address_md_path_target_parses_as_path() {
18317 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
18318 assert_eq!(
18319 a.target,
18320 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
18321 );
18322 }
18323
18324 #[test]
18325 fn address_rejects_malformed_forms() {
18326 for raw in [
18327 "",
18328 "@",
18329 "@/x",
18330 "@acme/",
18331 "@acme/../etc/passwd",
18332 "@acme/records/.hidden.md",
18333 "@ACME", "@acme/notes/x.txt", "@a b", ] {
18337 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
18338 }
18339 }
18340
18341 #[test]
18344 fn safe_paths_accept_store_shapes_and_reject_escapes() {
18345 for ok in [
18346 "DB.md",
18347 "assets.jsonl",
18348 "records/clients/lumio.md",
18349 "sources/emails/2026/07/x.md",
18350 ] {
18351 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
18352 }
18353 for bad in [
18354 "",
18355 "/etc/passwd",
18356 "../up.md",
18357 "records/../../up.md",
18358 "records//x.md",
18359 ".dbmd/config",
18360 "records/.hidden/x.md",
18361 "records/a b.md",
18362 "records\\win.md",
18363 ] {
18364 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
18365 }
18366 }
18367
18368 #[cfg(unix)]
18369 #[test]
18370 fn opened_destination_capability_survives_an_ancestor_path_swap() {
18371 use std::os::unix::fs::symlink;
18372
18373 let work = tempfile::tempdir().unwrap();
18374 let outside = tempfile::tempdir().unwrap();
18375 let original = work.path().join("destination");
18376 let moved = work.path().join("destination-moved");
18377 let directory = open_or_create_dir_nofollow(&original).unwrap();
18378
18379 std::fs::rename(&original, &moved).unwrap();
18380 symlink(outside.path(), &original).unwrap();
18381 write_pull_entries_beneath_dir(
18382 &directory,
18383 &[("records/note.md".to_string(), b"held inode".to_vec())],
18384 )
18385 .unwrap();
18386
18387 assert_eq!(
18388 std::fs::read(moved.join("records/note.md")).unwrap(),
18389 b"held inode"
18390 );
18391 assert!(!outside.path().join("records/note.md").exists());
18392 }
18393
18394 #[test]
18398 fn hub_config_flag_beats_file_and_requires_some_source() {
18399 let dir = tempfile::tempdir().unwrap();
18400 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
18401 std::fs::write(
18402 dir.path().join(CONFIG_REL_PATH),
18403 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
18404 )
18405 .unwrap();
18406
18407 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
18408 assert_eq!(from_flag.hub, "https://flag.example.com");
18409
18410 let from_file = hub_config(None, dir.path()).unwrap();
18411 assert_eq!(from_file.hub, "https://file.example.com");
18412
18413 let none = hub_config(None, tempfile::tempdir().unwrap().path());
18414 assert!(matches!(none, Err(LinkError::NoHub)));
18415 }
18416
18417 #[test]
18418 fn https_guard_allows_loopback_only_for_plain_http() {
18419 assert!(assert_safe_hub("https://hub.example.com").is_ok());
18420 assert!(assert_safe_hub("http://localhost:3000").is_ok());
18421 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
18422 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
18423 assert!(matches!(
18424 assert_safe_hub("http://hub.example.com"),
18425 Err(LinkError::UnsafeHub { .. })
18426 ));
18427 assert!(matches!(
18428 assert_safe_hub("hub.example.com"),
18429 Err(LinkError::UnsafeHub { .. })
18430 ));
18431 assert!(matches!(
18432 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
18433 Err(LinkError::UnsafeHub { .. })
18434 ));
18435 assert!(matches!(
18436 assert_safe_hub("https://hub.example.com@attacker.example"),
18437 Err(LinkError::UnsafeHub { .. })
18438 ));
18439 assert!(matches!(
18440 assert_safe_hub("https://hub.example.com/base"),
18441 Err(LinkError::UnsafeHub { .. })
18442 ));
18443 }
18444
18445 #[test]
18446 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
18447 for blocked in [
18448 "127.0.0.1",
18449 "10.0.0.1",
18450 "100.64.0.1",
18451 "169.254.169.254",
18452 "172.16.0.1",
18453 "192.168.0.1",
18454 "192.88.99.1",
18455 "198.18.0.1",
18456 "203.0.113.1",
18457 "::1",
18458 "fe80::1",
18459 "fd00::1",
18460 "2001:db8::1",
18461 "2001:1::1",
18462 "2002:7f00:1::",
18463 "3fff::1",
18464 ] {
18465 assert!(
18466 !is_public_registry_ip(blocked.parse().unwrap()),
18467 "must block {blocked}"
18468 );
18469 }
18470 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
18471 assert!(is_public_registry_ip(
18472 "2606:4700:4700::1111".parse().unwrap()
18473 ));
18474 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
18475 }
18476
18477 #[test]
18478 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
18479 use ureq::Resolver as _;
18480
18481 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
18482 let resolver = PinnedRegistryResolver {
18483 netloc: "home.example:443".to_string(),
18484 addresses: vec![pinned],
18485 };
18486 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
18487 assert!(resolver.resolve("127.0.0.1:443").is_err());
18488 assert_eq!(
18489 resolver.resolve("home.example:443").unwrap(),
18490 vec![pinned],
18491 "subsequent connects reuse the validated answer instead of DNS"
18492 );
18493 }
18494
18495 #[test]
18496 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
18497 let cfg = HubConfig {
18498 hub: "https://hub.example".to_string(),
18499 key: None,
18500 agent_key: None,
18501 brain_key: None,
18502 state_dir: tempfile::tempdir().unwrap().keep(),
18503 store_selected: false,
18504 };
18505 assert!(
18506 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
18507 "a production hub must not turn its presigned URL into an SSRF primitive"
18508 );
18509
18510 let store_selected = HubConfig {
18511 hub: "https://127.0.0.1".to_string(),
18512 store_selected: true,
18513 ..cfg
18514 };
18515 assert!(
18516 hub_agent(&store_selected).is_err(),
18517 "bytes in a cloned store must not select a private-network hub"
18518 );
18519 }
18520
18521 #[test]
18522 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
18523 assert_eq!(
18524 one_past_bounded_limit(MAX_PACK_BYTES),
18525 Some(MAX_PACK_BYTES + 1),
18526 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
18527 );
18528 assert_eq!(
18529 presigned_download_read_limit(),
18530 MAX_PACK_BYTES + 1,
18531 "the presigned reader is capped by the client constant, not a hub response"
18532 );
18533 assert_eq!(
18534 one_past_bounded_limit(u64::MAX),
18535 None,
18536 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
18537 );
18538 }
18539
18540 #[test]
18541 fn https_guard_matches_the_scheme_case_insensitively() {
18542 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
18545 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
18546 assert!(matches!(
18548 assert_safe_hub("HTTP://hub.example.com"),
18549 Err(LinkError::UnsafeHub { .. })
18550 ));
18551 }
18552
18553 #[test]
18554 fn clean_key_refuses_paste_artifacts_without_echoing() {
18555 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
18556 for bad in ["vc account", "vc\naccount", "ключ", ""] {
18557 let err = clean_key(bad).unwrap_err();
18558 assert!(matches!(err, LinkError::BadKey));
18559 assert!(
18560 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
18561 "error must not echo the key"
18562 );
18563 }
18564 }
18565
18566 fn dead_hub() -> HubConfig {
18572 HubConfig {
18573 hub: "http://127.0.0.1:9".to_string(),
18574 key: Some("k".to_string()),
18575 agent_key: None,
18576 brain_key: None,
18577 state_dir: PathBuf::from("."),
18578 store_selected: false,
18579 }
18580 }
18581
18582 #[test]
18583 fn request_retries_a_connection_failure_before_sending() {
18584 use std::io::{Read as _, Write as _};
18585 use std::net::TcpListener;
18586 use std::thread;
18587 use std::time::Duration;
18588
18589 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
18590 let address = probe.local_addr().unwrap();
18591 drop(probe);
18592 let server = thread::spawn(move || {
18593 thread::sleep(Duration::from_millis(40));
18594 let listener = TcpListener::bind(address).unwrap();
18595 let (mut stream, _) = listener.accept().unwrap();
18596 let mut request_bytes = [0_u8; 1024];
18597 let _ = stream.read(&mut request_bytes).unwrap();
18598 stream
18599 .write_all(
18600 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18601 )
18602 .unwrap();
18603 });
18604 let cfg = HubConfig {
18605 hub: format!("http://{address}"),
18606 key: None,
18607 agent_key: None,
18608 brain_key: None,
18609 state_dir: tempfile::tempdir().unwrap().keep(),
18610 store_selected: false,
18611 };
18612
18613 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
18614 assert_eq!(response.status, 200);
18615 assert_eq!(response.body, Some(json!({ "ok": true })));
18616 server.join().unwrap();
18617 }
18618
18619 #[test]
18620 fn a_commit_goes_back_for_a_receipt_it_lost() {
18621 use std::io::Write as _;
18622 use std::net::TcpListener;
18623 use std::thread;
18624
18625 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18631 let address = listener.local_addr().unwrap();
18632 let server = thread::spawn(move || {
18633 let (mut first, _) = listener.accept().unwrap();
18635 drain_test_http_request(&mut first);
18636 first
18637 .write_all(
18638 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
18639 )
18640 .unwrap();
18641 drop(first);
18642 let (mut second, _) = listener.accept().unwrap();
18644 drain_test_http_request(&mut second);
18645 second
18646 .write_all(
18647 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\"}",
18648 )
18649 .unwrap();
18650 });
18651 let cfg = HubConfig {
18652 hub: format!("http://{address}"),
18653 key: Some("k".to_string()),
18654 agent_key: None,
18655 brain_key: None,
18656 state_dir: tempfile::tempdir().unwrap().keep(),
18657 store_selected: false,
18658 };
18659
18660 let response = request_patient(
18661 &cfg,
18662 "POST",
18663 "/api/hub/brains/b/v2/commits",
18664 Some(&json!({ "mutation_id": "dbmd-1" })),
18665 Auth::Required,
18666 )
18667 .expect("the receipt is collected on the second ask");
18668 assert_eq!(response.status, 200);
18669 assert_eq!(
18670 response
18671 .body
18672 .as_ref()
18673 .and_then(|value| value.get("outcome"))
18674 .and_then(Value::as_str),
18675 Some("converged"),
18676 "an already-applied mutation answers with its receipt"
18677 );
18678 server.join().unwrap();
18679 }
18680
18681 #[test]
18682 fn a_patient_commit_waits_for_its_typed_post_acceptance_receipt_lag() {
18683 use std::io::Write as _;
18684 use std::net::TcpListener;
18685 use std::thread;
18686
18687 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18688 let address = listener.local_addr().unwrap();
18689 let server = thread::spawn(move || {
18690 let lag = br#"{"error":"validation recovery state is not at the source head","details":{"code":"validation_index_catching_up"}}"#;
18691 let receipt = br#"{"v":2,"outcome":"converged"}"#;
18692 for (status, body) in [
18693 ("422 Unprocessable Entity", lag.as_slice()),
18694 ("200 OK", receipt.as_slice()),
18695 ] {
18696 let (mut stream, _) = listener.accept().unwrap();
18697 drain_test_http_request(&mut stream);
18698 write!(
18699 stream,
18700 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
18701 body.len()
18702 )
18703 .unwrap();
18704 stream.write_all(body).unwrap();
18705 }
18706 });
18707 let cfg = HubConfig {
18708 hub: format!("http://{address}"),
18709 key: Some("k".to_string()),
18710 agent_key: None,
18711 brain_key: None,
18712 state_dir: tempfile::tempdir().unwrap().keep(),
18713 store_selected: false,
18714 };
18715
18716 let response = request_patient(
18717 &cfg,
18718 "POST",
18719 "/api/hub/brains/b/v2/commits",
18720 Some(&json!({ "mutation_id": "dbmd-1" })),
18721 Auth::Required,
18722 )
18723 .expect("typed projection lag is retried until the exact receipt is available");
18724 assert_eq!(response.status, 200);
18725 assert_eq!(
18726 response
18727 .body
18728 .as_ref()
18729 .and_then(|value| value.get("outcome"))
18730 .and_then(Value::as_str),
18731 Some("converged")
18732 );
18733 server.join().unwrap();
18734 }
18735
18736 #[test]
18737 fn a_mutation_body_that_dies_mid_stream_is_a_transport_failure() {
18738 use std::io::{Read as _, Write as _};
18739 use std::net::TcpListener;
18740 use std::thread;
18741
18742 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18748 let address = listener.local_addr().unwrap();
18749 let server = thread::spawn(move || {
18750 let (mut stream, _) = listener.accept().unwrap();
18751 let mut request_bytes = [0_u8; 1024];
18752 let _ = stream.read(&mut request_bytes).unwrap();
18753 stream
18755 .write_all(
18756 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18757 )
18758 .unwrap();
18759 });
18760 let cfg = HubConfig {
18761 hub: format!("http://{address}"),
18762 key: None,
18763 agent_key: None,
18764 brain_key: None,
18765 state_dir: tempfile::tempdir().unwrap().keep(),
18766 store_selected: false,
18767 };
18768
18769 let error = request(&cfg, "POST", "/truncated", None, Auth::None)
18770 .expect_err("a truncated body must not read as success");
18771 match error {
18772 LinkError::Transport { hub, .. } => {
18773 assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
18774 }
18775 other => panic!("expected a transport failure, got {other:?}"),
18776 }
18777 server.join().unwrap();
18778 }
18779
18780 #[test]
18781 fn a_safe_get_retries_when_its_body_dies_mid_stream() {
18782 use std::io::{Read as _, Write as _};
18783 use std::net::{TcpListener, TcpStream};
18784 use std::thread;
18785
18786 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18787 let address = listener.local_addr().unwrap();
18788 let server = thread::spawn(move || {
18789 let read_request = |stream: &mut TcpStream| {
18790 let mut request = Vec::new();
18791 let mut bytes = [0_u8; 1024];
18792 loop {
18793 let read = stream.read(&mut bytes).unwrap();
18794 if read == 0 {
18795 break;
18796 }
18797 request.extend_from_slice(&bytes[..read]);
18798 let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
18799 else {
18800 continue;
18801 };
18802 let headers = String::from_utf8_lossy(&request[..header_end]);
18803 let content_length = headers
18804 .lines()
18805 .find_map(|line| {
18806 let (name, value) = line.split_once(':')?;
18807 name.eq_ignore_ascii_case("content-length")
18808 .then(|| value.trim().parse::<usize>().ok())
18809 .flatten()
18810 })
18811 .unwrap_or(0);
18812 if request.len() >= header_end + 4 + content_length {
18813 break;
18814 }
18815 }
18816 };
18817 let (mut first, _) = listener.accept().unwrap();
18818 read_request(&mut first);
18819 first
18820 .write_all(
18821 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18822 )
18823 .unwrap();
18824 drop(first);
18825
18826 let (mut second, _) = listener.accept().unwrap();
18827 read_request(&mut second);
18828 second
18829 .write_all(
18830 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18831 )
18832 .unwrap();
18833 });
18834 let cfg = HubConfig {
18835 hub: format!("http://{address}"),
18836 key: None,
18837 agent_key: None,
18838 brain_key: None,
18839 state_dir: tempfile::tempdir().unwrap().keep(),
18840 store_selected: false,
18841 };
18842
18843 let response = request(&cfg, "GET", "/retry-body", None, Auth::None)
18844 .expect("a safe read retries the interrupted body");
18845 assert_eq!(response.status, 200);
18846 assert_eq!(response.body, Some(json!({ "ok": true })));
18847 server.join().unwrap();
18848 }
18849
18850 #[test]
18851 fn an_explicit_read_only_post_retries_when_its_body_dies_mid_stream() {
18852 use std::io::{Read as _, Write as _};
18853 use std::net::{TcpListener, TcpStream};
18854 use std::thread;
18855
18856 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18857 let address = listener.local_addr().unwrap();
18858 let server = thread::spawn(move || {
18859 let read_request = |stream: &mut TcpStream| {
18860 let mut request = Vec::new();
18861 let mut bytes = [0_u8; 1024];
18862 loop {
18863 let read = stream.read(&mut bytes).unwrap();
18864 if read == 0 {
18865 break;
18866 }
18867 request.extend_from_slice(&bytes[..read]);
18868 let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
18869 else {
18870 continue;
18871 };
18872 let headers = String::from_utf8_lossy(&request[..header_end]);
18873 let content_length = headers
18874 .lines()
18875 .find_map(|line| {
18876 let (name, value) = line.split_once(':')?;
18877 name.eq_ignore_ascii_case("content-length")
18878 .then(|| value.trim().parse::<usize>().ok())
18879 .flatten()
18880 })
18881 .unwrap_or(0);
18882 if request.len() >= header_end + 4 + content_length {
18883 break;
18884 }
18885 }
18886 };
18887 let (mut first, _) = listener.accept().unwrap();
18888 read_request(&mut first);
18889 first
18890 .write_all(
18891 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18892 )
18893 .unwrap();
18894 drop(first);
18895
18896 let (mut second, _) = listener.accept().unwrap();
18897 read_request(&mut second);
18898 second
18899 .write_all(
18900 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18901 )
18902 .unwrap();
18903 });
18904 let cfg = HubConfig {
18905 hub: format!("http://{address}"),
18906 key: None,
18907 agent_key: None,
18908 brain_key: None,
18909 state_dir: tempfile::tempdir().unwrap().keep(),
18910 store_selected: false,
18911 };
18912
18913 let response = request_raw_retryable_read(
18914 &cfg,
18915 "POST",
18916 "/v2/stream",
18917 Some(&json!({ "files": ["proof"] })),
18918 Auth::None,
18919 1_024,
18920 )
18921 .expect("an explicitly safe POST retries the interrupted body");
18922 assert_eq!(response.status, 200);
18923 assert_eq!(
18924 serde_json::from_slice::<Value>(&response.body).unwrap(),
18925 json!({ "ok": true })
18926 );
18927 server.join().unwrap();
18928 }
18929
18930 #[test]
18931 fn object_store_transport_errors_never_render_presigned_urls() {
18932 use std::net::TcpListener;
18933
18934 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18935 let address = listener.local_addr().unwrap();
18936 drop(listener);
18937 let signature = "do-not-render-this-presigned-signature";
18938 let raw =
18939 format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
18940 let error = ureq::get(&raw)
18941 .timeout(std::time::Duration::from_millis(250))
18942 .call()
18943 .expect_err("the closed local port must fail");
18944 let ureq::Error::Transport(transport) = error else {
18945 panic!("expected a transport failure");
18946 };
18947
18948 let rendered = object_store_transport_error(transport).to_string();
18949 assert!(rendered.contains("the object store"));
18950 assert!(rendered.contains("network error"));
18951 assert!(!rendered.contains(&raw));
18952 assert!(!rendered.contains(signature));
18953 assert!(!rendered.contains("X-Amz-"));
18954 }
18955
18956 #[test]
18957 fn endpoint_cap_refuses_a_body_before_json_parsing() {
18958 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
18959 let cfg = HubConfig {
18960 hub,
18961 key: None,
18962 agent_key: None,
18963 brain_key: None,
18964 state_dir: tempfile::tempdir().unwrap().keep(),
18965 store_selected: false,
18966 };
18967
18968 assert!(matches!(
18969 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
18970 Err(LinkError::ResponseTooLarge { .. })
18971 ));
18972 server.join().unwrap();
18973 }
18974
18975 #[test]
18976 fn overall_deadline_stops_a_dribbled_response_body() {
18977 use std::io::{Read as _, Write as _};
18978 use std::net::TcpListener;
18979 use std::time::{Duration, Instant};
18980
18981 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18982 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
18983 let server = std::thread::spawn(move || {
18984 let (mut stream, _) = listener.accept().unwrap();
18985 let mut request = [0_u8; 1024];
18986 let _ = stream.read(&mut request);
18987 stream
18988 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
18989 .unwrap();
18990 for byte in [b'x'; 32] {
18991 if stream.write_all(&[byte]).is_err() {
18992 break;
18993 }
18994 std::thread::sleep(Duration::from_millis(40));
18995 }
18996 });
18997 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
18998 let started = Instant::now();
18999 let response = http.get(&url).call().unwrap();
19000 let mut body = Vec::new();
19001 let error = response
19002 .into_reader()
19003 .read_to_end(&mut body)
19004 .expect_err("per-read progress must not reset the overall deadline");
19005 assert!(
19006 started.elapsed() < Duration::from_millis(700),
19007 "dribbled body exceeded the wall-clock budget: {error}"
19008 );
19009 server.join().unwrap();
19010 }
19011
19012 #[test]
19013 fn overall_deadline_stops_a_stalled_upload() {
19014 use std::net::TcpListener;
19015 use std::time::{Duration, Instant};
19016
19017 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19018 let url = format!("http://{}/upload", listener.local_addr().unwrap());
19019 let server = std::thread::spawn(move || {
19020 let (_stream, _) = listener.accept().unwrap();
19021 std::thread::sleep(Duration::from_millis(600));
19024 });
19025 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
19026 let body = vec![0x5a; 32 * 1024 * 1024];
19027 let started = Instant::now();
19028 let error = http
19029 .put(&url)
19030 .send_bytes(&body)
19031 .expect_err("stalled request-body writes must time out");
19032 assert!(
19033 started.elapsed() < Duration::from_millis(700),
19034 "stalled upload exceeded the wall-clock budget: {error}"
19035 );
19036 server.join().unwrap();
19037 }
19038
19039 #[test]
19040 fn presigned_source_retries_share_one_upload_deadline() {
19041 use std::net::TcpListener;
19042 use std::time::{Duration, Instant};
19043
19044 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19045 let address = listener.local_addr().unwrap();
19046 let signature = "do-not-render-this-stalled-upload-signature";
19047 let url = format!(
19048 "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
19049 );
19050 let server = std::thread::spawn(move || {
19051 let (_stream, _) = listener.accept().unwrap();
19052 std::thread::sleep(Duration::from_millis(600));
19056 });
19057
19058 let directory = tempfile::tempdir().unwrap();
19059 std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
19060 std::fs::create_dir(directory.path().join("records")).unwrap();
19061 let relative = "records/stalled.bin";
19062 let bytes = vec![0x5a; 32 * 1024 * 1024];
19063 std::fs::write(directory.path().join(relative), &bytes).unwrap();
19064 let store = Store::open_strict(directory.path()).unwrap();
19065 let cfg = HubConfig {
19066 hub: format!("http://{address}"),
19067 key: None,
19068 agent_key: None,
19069 brain_key: None,
19070 state_dir: tempfile::tempdir().unwrap().keep(),
19071 store_selected: false,
19072 };
19073 let source = V2UploadSource {
19074 path: relative.to_string(),
19075 bytes: bytes.len() as u64,
19076 };
19077
19078 let started = Instant::now();
19079 let error = put_presigned_source_with_budget(
19080 &cfg,
19081 &url,
19082 &json!({ "content-length": source.bytes.to_string() }),
19083 &store,
19084 &source,
19085 None,
19086 Duration::from_millis(150),
19087 )
19088 .expect_err("a black-holed upload must leave at its shared deadline");
19089 assert!(
19090 started.elapsed() < Duration::from_millis(700),
19091 "presigned retries exceeded their shared budget: {error}"
19092 );
19093 let rendered = error.to_string();
19094 assert!(rendered.contains("the object store"));
19095 assert!(!rendered.contains(&url));
19096 assert!(!rendered.contains(signature));
19097 server.join().unwrap();
19098 }
19099
19100 #[test]
19101 fn verb_entry_gates_accept_the_hub_ref_shapes() {
19102 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
19103 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
19104 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
19105 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
19106 }
19107 }
19108
19109 #[test]
19110 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
19111 let cfg = dead_hub();
19112 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
19113 assert!(
19114 matches!(
19115 sync_pull(&cfg, bad, None),
19116 Err(LinkError::BadAddress { .. })
19117 ),
19118 "sync_pull must refuse {bad:?}"
19119 );
19120 assert!(
19121 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
19122 "sync_push must refuse {bad:?}"
19123 );
19124 assert!(
19125 matches!(
19126 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
19127 Err(LinkError::BadAddress { .. })
19128 ),
19129 "grant_issue must refuse {bad:?}"
19130 );
19131 assert!(
19132 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
19133 "grant_list must refuse {bad:?}"
19134 );
19135 assert!(
19136 matches!(
19137 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
19138 Err(LinkError::BadAddress { .. })
19139 ),
19140 "grant_revoke must refuse brain {bad:?}"
19141 );
19142 assert!(
19143 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
19144 "head must refuse {bad:?}"
19145 );
19146 }
19147 }
19148
19149 #[test]
19150 fn grant_revoke_refuses_url_reshaping_grant_ids() {
19151 let cfg = dead_hub();
19152 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
19153 assert!(
19154 matches!(
19155 grant_revoke(&cfg, "acme", bad),
19156 Err(LinkError::BadGrantId { .. })
19157 ),
19158 "grant_revoke must refuse grant id {bad:?}"
19159 );
19160 }
19161 }
19162
19163 #[test]
19164 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
19165 let cfg = dead_hub();
19166 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
19167 assert!(
19168 matches!(
19169 propose(&cfg, bad, "intake", "hi"),
19170 Err(LinkError::BadAddress { .. })
19171 ),
19172 "propose must refuse handle {bad:?}"
19173 );
19174 }
19175 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
19176 assert!(matches!(
19177 propose(&cfg, "acme-site", "intake", &oversize),
19178 Err(LinkError::ProposeTooLarge { .. })
19179 ));
19180 assert!(matches!(
19183 propose(&cfg, "acme-site", "intake", "hi"),
19184 Err(LinkError::Transport { .. })
19185 ));
19186 }
19187
19188 #[test]
19189 fn resolve_refuses_a_hand_built_unsafe_address() {
19190 let cfg = dead_hub();
19191 for brain in ["../up", "a/b", "a?x", "a#f"] {
19192 let addr = Address {
19193 brain: brain.to_string(),
19194 target: None,
19195 };
19196 assert!(
19197 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
19198 "resolve must refuse brain {brain:?}"
19199 );
19200 }
19201 for target in [
19202 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
19203 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
19205 AddressTarget::Path("records/x.md#frag".to_string()),
19206 ] {
19207 let addr = Address {
19208 brain: "acme".to_string(),
19209 target: Some(target.clone()),
19210 };
19211 assert!(
19212 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
19213 "resolve must refuse target {target:?}"
19214 );
19215 }
19216 }
19217
19218 #[test]
19219 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
19220 let mut local = std::collections::BTreeMap::new();
19221 local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
19222 local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
19223 let mut remote = std::collections::BTreeMap::new();
19224 remote.insert(
19225 "records/a.md".to_string(),
19226 V2BaselineFile {
19227 sha256: "c".repeat(64),
19228 bytes: 1,
19229 proof: None,
19230 },
19231 );
19232 remote.insert(
19233 "records/b.md".to_string(),
19234 V2BaselineFile {
19235 sha256: "b".repeat(64),
19236 bytes: 1,
19237 proof: None,
19238 },
19239 );
19240 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
19241 }
19242
19243 #[test]
19244 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
19245 let local = std::collections::BTreeMap::new();
19246 let mut remote = std::collections::BTreeMap::new();
19247 remote.insert(
19248 "private/local.md".to_string(),
19249 V2BaselineFile {
19250 sha256: "d".repeat(64),
19251 bytes: 1,
19252 proof: None,
19253 },
19254 );
19255 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
19256 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
19257 }
19258
19259 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
19260 V2VerifiedHead {
19261 requested: TEST_BRAIN_ID.to_string(),
19262 brain_id: TEST_BRAIN_ID.to_string(),
19263 view_kind: "scoped".to_string(),
19264 view_revision: revision.to_string(),
19265 control_revision: revision.to_string(),
19266 identity: V2HeadIdentity {
19267 custody: "hub".to_string(),
19268 fingerprint: "test".to_string(),
19269 public_key_spki: "test".to_string(),
19270 previous: Vec::new(),
19271 rotations: Vec::new(),
19272 },
19273 pointer: None,
19274 trust: TrustState {
19275 v: 2,
19276 origin: "https://hub.example".to_string(),
19277 requested: TEST_BRAIN_ID.to_string(),
19278 brain: TEST_BRAIN_ID.to_string(),
19279 home: None,
19280 anchor: "ed25519:test".to_string(),
19281 current: "ed25519:test".to_string(),
19282 head_seq: 0,
19283 feed_hash: None,
19284 rotations: Vec::new(),
19285 hub_signer: None,
19286 protocol_profile: Some("link-v2".to_string()),
19287 },
19288 alias: None,
19289 }
19290 }
19291
19292 #[test]
19293 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
19294 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
19295 assert!(accepted_as_v2(&trust));
19296
19297 trust.protocol_profile = None;
19298 trust.hub_signer = Some("ed25519:hub".to_string());
19299 assert!(accepted_as_v2(&trust));
19300
19301 trust.hub_signer = None;
19302 assert!(!accepted_as_v2(&trust));
19303 }
19304
19305 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
19306 V2SyncBaseline {
19307 v: 2,
19308 origin: "https://hub.example".to_string(),
19309 brain: TEST_BRAIN_ID.to_string(),
19310 checkout_id: Some("c".repeat(64)),
19311 head_seq: Some(0),
19312 commit_hash: None,
19313 content_root: None,
19314 asset_root: None,
19315 assets: std::collections::BTreeMap::new(),
19316 view_kind: Some("scoped".to_string()),
19317 view_revision: Some(revision.to_string()),
19318 control_revision: Some(revision.to_string()),
19319 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
19320 files: std::collections::BTreeMap::new(),
19321 scan_cache: std::collections::BTreeMap::new(),
19322 local_policy_digest: None,
19323 local_eligibility: std::collections::BTreeMap::new(),
19324 remote_copy_remains: std::collections::BTreeMap::new(),
19325 }
19326 }
19327
19328 #[test]
19329 fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
19330 let cfg = test_hub_config(
19331 "https://hub.example".to_string(),
19332 tempfile::tempdir().unwrap().keep(),
19333 );
19334 let mut baseline = scoped_test_baseline(&"a".repeat(64));
19335 baseline.assets.insert(
19336 "assets/archive.bin".to_string(),
19337 V2BaselineAsset {
19338 blob_sha256: "b".repeat(64),
19339 bytes: MAX_STORE_BYTES + 1,
19340 media_type: "application/octet-stream".to_string(),
19341 wrappers: vec!["records/archive.md".to_string()],
19342 required: true,
19343 disposition: "hosted".to_string(),
19344 leaf_hash: "c".repeat(64),
19345 },
19346 );
19347
19348 let accepted = serde_json::to_vec(&baseline).unwrap();
19349 assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
19350
19351 baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
19352 let refused = serde_json::to_vec(&baseline).unwrap();
19353 assert!(matches!(
19354 parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
19355 Err(LinkError::InvalidFeed { .. })
19356 ));
19357 }
19358
19359 #[cfg(unix)]
19360 #[test]
19361 fn v2_stat_cache_waits_out_racy_files_and_binds_file_identity() {
19362 use std::os::unix::fs::MetadataExt as _;
19363
19364 let directory = tempfile::tempdir().unwrap();
19365 let first = directory.path().join("first.md");
19366 let second = directory.path().join("second.md");
19367 std::fs::write(&first, b"same").unwrap();
19368 std::fs::write(&second, b"same").unwrap();
19369 let first = std::fs::metadata(first).unwrap();
19370 let second = std::fs::metadata(second).unwrap();
19371 let observed_ns = |metadata: &std::fs::Metadata| {
19372 let mtime =
19373 i128::from(metadata.mtime()) * 1_000_000_000 + i128::from(metadata.mtime_nsec());
19374 let ctime =
19375 i128::from(metadata.ctime()) * 1_000_000_000 + i128::from(metadata.ctime_nsec());
19376 mtime.max(ctime)
19377 };
19378
19379 assert!(v2_scan_fingerprint_at(&first, observed_ns(&first) + 1_000_000_000).is_none());
19380 let first_fingerprint =
19381 v2_scan_fingerprint_at(&first, observed_ns(&first) + 3_000_000_000).unwrap();
19382 let second_fingerprint =
19383 v2_scan_fingerprint_at(&second, observed_ns(&second) + 3_000_000_000).unwrap();
19384 assert_ne!(first_fingerprint, second_fingerprint);
19385 }
19386
19387 #[test]
19388 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
19389 let directory = tempfile::tempdir().unwrap();
19390 std::fs::write(
19391 directory.path().join("DB.md"),
19392 scoped_projection_bytes(TEST_BRAIN_ID),
19393 )
19394 .unwrap();
19395 let store = Store::open_strict(directory.path()).unwrap();
19396 let head = scoped_test_head(&"a".repeat(64));
19397 let baseline = scoped_test_baseline(&"a".repeat(64));
19398 let mut view = v2_local_files(&store).unwrap();
19399 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
19400 assert!(!view.riding.contains_key("DB.md"));
19401 assert!(!view.eligibility.contains_key("DB.md"));
19402 }
19403
19404 #[test]
19405 fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
19406 let directory = tempfile::tempdir().unwrap();
19407 std::fs::write(
19408 directory.path().join("DB.md"),
19409 scoped_projection_bytes(TEST_BRAIN_ID),
19410 )
19411 .unwrap();
19412 let store = Store::open_strict(directory.path()).unwrap();
19413 let head = scoped_test_head(&"a".repeat(64));
19414 let baseline = scoped_test_baseline(&"a".repeat(64));
19415
19416 let mut carried = v2_local_files(&store).unwrap();
19417 remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
19418 let handed_off =
19419 local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
19420 assert!(!handed_off.riding.contains_key("DB.md"));
19421
19422 let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
19423 assert!(!freshly_scanned.riding.contains_key("DB.md"));
19424
19425 std::fs::write(
19426 directory.path().join("DB.md"),
19427 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
19428 )
19429 .unwrap();
19430 let tampered = Store::open_strict(directory.path()).unwrap();
19431 assert!(matches!(
19432 local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
19433 Err(LinkError::ScopedProjectionModified)
19434 ));
19435 }
19436
19437 #[test]
19438 fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
19439 let directory = tempfile::tempdir().unwrap();
19440 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
19441 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
19442 std::fs::write(
19443 directory.path().join("DB.md"),
19444 b"---\nname: Kept home test\n---\n",
19445 )
19446 .unwrap();
19447 std::fs::write(
19448 directory.path().join("records/notes/a.md"),
19449 b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
19450 )
19451 .unwrap();
19452 std::fs::write(
19453 directory.path().join("sources/private/secret.md"),
19454 b"---\ntype: note\n---\nlocal only\n",
19455 )
19456 .unwrap();
19457 std::fs::write(
19458 directory.path().join("sources/private/unlinked.md"),
19459 b"---\ntype: note\n---\nnot disclosed\n",
19460 )
19461 .unwrap();
19462 std::fs::write(
19463 directory.path().join(".sevralocal"),
19464 b"sources/private/**\n",
19465 )
19466 .unwrap();
19467
19468 let store = Store::open_strict(directory.path()).unwrap();
19469 let view = v2_local_files(&store).unwrap();
19470 assert!(!view.riding.contains_key("sources/private/secret.md"));
19471 assert_eq!(
19472 view.withheld_links,
19473 vec![V2WithheldLink {
19474 source: "records/notes/a.md".to_string(),
19475 target: "sources/private/secret.md".to_string(),
19476 }]
19477 );
19478 }
19479
19480 #[test]
19481 fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
19482 let directory = tempfile::tempdir().unwrap();
19487 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
19488 std::fs::write(
19489 directory.path().join("DB.md"),
19490 b"---\nname: Restored export\n---\n",
19491 )
19492 .unwrap();
19493 std::fs::write(
19494 directory.path().join("records/notes/a.md"),
19495 b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
19496 )
19497 .unwrap();
19498 std::fs::write(
19499 directory.path().join(".sevralocal"),
19500 b"sources/private/**\n",
19501 )
19502 .unwrap();
19503
19504 let store = Store::open_strict(directory.path()).unwrap();
19505 let view = v2_local_files(&store).unwrap();
19506 assert_eq!(
19507 view.withheld_links,
19508 vec![V2WithheldLink {
19509 source: "records/notes/a.md".to_string(),
19510 target: "sources/private/absent.md".to_string(),
19511 }]
19512 );
19513 std::fs::write(
19515 directory.path().join("records/notes/b.md"),
19516 b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
19517 )
19518 .unwrap();
19519 let store = Store::open_strict(directory.path()).unwrap();
19520 let view = v2_local_files(&store).unwrap();
19521 assert!(
19522 !view
19523 .withheld_links
19524 .iter()
19525 .any(|link| link.target == "records/notes/nowhere.md"),
19526 "an unclaimed dangling target must not be declared withheld"
19527 );
19528 }
19529
19530 #[test]
19531 fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
19532 let directory = tempfile::tempdir().unwrap();
19533 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
19534 std::fs::write(
19535 directory.path().join("DB.md"),
19536 b"---\nname: Withdrawal test\n---\n",
19537 )
19538 .unwrap();
19539 let source = b"---\ntype: note\n---\nlocal evidence\n";
19540 std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
19541 std::fs::write(
19542 directory.path().join(".sevralocal"),
19543 b"sources/private/**\n",
19544 )
19545 .unwrap();
19546 let store = Store::open_strict(directory.path()).unwrap();
19547 let view = v2_local_files(&store).unwrap();
19548 let mut remote = std::collections::BTreeMap::new();
19549 remote.insert(
19550 "sources/private/evidence.md".to_string(),
19551 V2BaselineFile {
19552 sha256: content_sha256(source),
19553 bytes: source.len() as u64,
19554 proof: None,
19555 },
19556 );
19557 assert_eq!(
19558 v2_content_withdrawal_operation(
19559 &store,
19560 &view,
19561 &remote,
19562 "sources/private/evidence.md",
19563 "approved retention change",
19564 )
19565 .unwrap(),
19566 json!({
19567 "op": "withdraw_from_hosting",
19568 "path": "sources/private/evidence.md",
19569 "expected": { "kind": "blob", "hash": content_sha256(source) },
19570 "reason": "approved retention change",
19571 })
19572 );
19573
19574 std::fs::write(
19575 directory.path().join("sources/private/evidence.md"),
19576 b"changed after review",
19577 )
19578 .unwrap();
19579 assert!(matches!(
19580 v2_content_withdrawal_operation(
19581 &store,
19582 &view,
19583 &remote,
19584 "sources/private/evidence.md",
19585 "approved retention change",
19586 ),
19587 Err(LinkError::InvalidPack { .. })
19588 ));
19589 }
19590
19591 #[test]
19592 fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
19593 let directory = tempfile::tempdir().unwrap();
19594 std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
19595 std::fs::write(
19596 directory.path().join("DB.md"),
19597 b"---\nname: Asset withdrawal test\n---\n",
19598 )
19599 .unwrap();
19600 let bytes = b"private binary";
19601 std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
19602 std::fs::write(
19603 directory.path().join(".sevralocal"),
19604 b"sources/files/private.pdf\n",
19605 )
19606 .unwrap();
19607 let store = Store::open_strict(directory.path()).unwrap();
19608 let view = v2_local_files(&store).unwrap();
19609 let local = crate::AssetRecord {
19610 path: "sources/files/private.pdf".to_string(),
19611 sha256: content_sha256(bytes),
19612 bytes: bytes.len() as u64,
19613 media_type: "application/pdf".to_string(),
19614 wrappers: vec![
19615 "sources/files/private.md".to_string(),
19616 "sources/redacted/private.md".to_string(),
19617 ],
19618 required: false,
19619 };
19620 let current = V2BaselineAsset {
19621 blob_sha256: local.sha256.clone(),
19622 bytes: local.bytes,
19623 media_type: local.media_type.clone(),
19624 wrappers: vec!["sources/files/private.md".to_string()],
19625 required: true,
19626 disposition: "hosted".to_string(),
19627 leaf_hash: "d".repeat(64),
19628 };
19629 assert_eq!(
19630 v2_asset_withdrawal_operation(
19631 &store,
19632 &view,
19633 &local.path,
19634 &local,
19635 ¤t,
19636 "approved retention change",
19637 )
19638 .unwrap(),
19639 json!({
19640 "op": "asset_withdraw",
19641 "path": local.path,
19642 "expected": { "kind": "asset", "hash": "d".repeat(64) },
19643 "asset": {
19644 "blob_sha256": local.sha256.clone(),
19645 "bytes": local.bytes,
19646 "media_type": local.media_type.clone(),
19647 "wrappers": local.wrappers.clone(),
19648 "required": false,
19649 "disposition": "withheld",
19650 },
19651 "reason": "approved retention change",
19652 })
19653 );
19654
19655 let mut mismatched = current.clone();
19656 mismatched.blob_sha256 = "f".repeat(64);
19657 assert!(matches!(
19658 v2_asset_withdrawal_operation(
19659 &store,
19660 &view,
19661 &local.path,
19662 &local,
19663 &mismatched,
19664 "approved retention change",
19665 ),
19666 Err(LinkError::InvalidPack { .. })
19667 ));
19668 }
19669
19670 #[test]
19671 fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
19672 let first = v2_checkout_id(None).unwrap();
19673 assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
19674 assert_ne!(first, v2_checkout_id(None).unwrap());
19675 assert!(is_sha256(&first));
19676 }
19677
19678 #[test]
19679 fn moved_checkout_relocates_its_path_bound_baseline_without_rehash_ambiguity() {
19680 let sandbox = tempfile::tempdir().unwrap();
19681 let stage_root = sandbox.path().join("stage");
19682 let live_root = sandbox.path().join("live");
19683 let from = stage_root.join("db");
19684 let to = live_root.join("db");
19685 std::fs::create_dir_all(from.join("records/items")).unwrap();
19686 std::fs::write(
19687 from.join("DB.md"),
19688 b"---\ntype: db-md\nscope: company\nowner: test\n---\n",
19689 )
19690 .unwrap();
19691 std::fs::write(
19692 from.join("records/items/example.md"),
19693 b"---\ntype: item\n---\n\n# Example\n",
19694 )
19695 .unwrap();
19696 let cfg = test_hub_config(
19697 "https://hub.example".to_string(),
19698 sandbox.path().join("state"),
19699 );
19700 let store = Store::open_strict(&from).unwrap();
19701 let local = v2_local_files(&store).unwrap();
19702 let files = local
19703 .riding
19704 .iter()
19705 .map(|(path, (sha256, bytes))| {
19706 (
19707 path.clone(),
19708 V2BaselineFile {
19709 sha256: sha256.clone(),
19710 bytes: *bytes,
19711 proof: None,
19712 },
19713 )
19714 })
19715 .collect();
19716 let baseline = V2SyncBaseline {
19717 v: 2,
19718 origin: "https://hub.example".to_string(),
19719 brain: TEST_BRAIN_ID.to_string(),
19720 checkout_id: Some("c".repeat(64)),
19721 head_seq: Some(7),
19722 commit_hash: Some("a".repeat(64)),
19723 content_root: Some("b".repeat(64)),
19724 asset_root: None,
19725 assets: std::collections::BTreeMap::new(),
19726 view_kind: Some("full".to_string()),
19727 view_revision: Some("d".repeat(64)),
19728 control_revision: Some("e".repeat(64)),
19729 projection_sha256: None,
19730 files,
19731 scan_cache: local.scan_cache.clone(),
19732 local_policy_digest: Some(local.policy.digest.clone()),
19733 local_eligibility: local.eligibility.clone(),
19734 remote_copy_remains: std::collections::BTreeMap::new(),
19735 };
19736 save_v2_baseline(&cfg, TEST_BRAIN_ID, &from, &baseline).unwrap();
19737 std::fs::rename(&stage_root, &live_root).unwrap();
19738
19739 let first = relocate_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from, &to).unwrap();
19740 assert_eq!(first.get("moved").and_then(Value::as_bool), Some(true));
19741 assert!(!has_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from).unwrap());
19742 assert!(has_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &to).unwrap());
19743
19744 let retry = relocate_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from, &to).unwrap();
19745 assert_eq!(retry.get("moved").and_then(Value::as_bool), Some(false));
19746 }
19747
19748 #[test]
19749 fn scoped_projection_edit_and_scope_transition_fail_closed() {
19750 let directory = tempfile::tempdir().unwrap();
19751 std::fs::write(
19752 directory.path().join("DB.md"),
19753 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
19754 )
19755 .unwrap();
19756 let store = Store::open_strict(directory.path()).unwrap();
19757 let head = scoped_test_head(&"a".repeat(64));
19758 let baseline = scoped_test_baseline(&"a".repeat(64));
19759 let mut view = v2_local_files(&store).unwrap();
19760 assert!(matches!(
19761 remove_scoped_projection(&head, Some(&baseline), &mut view),
19762 Err(LinkError::ScopedProjectionModified)
19763 ));
19764
19765 let changed = scoped_test_head(&"b".repeat(64));
19766 assert!(matches!(
19767 ensure_v2_view_compatible(&changed, Some(&baseline)),
19768 Err(LinkError::ScopedViewChanged)
19769 ));
19770
19771 let mut same_view_new_control = head.clone();
19772 same_view_new_control.control_revision = "c".repeat(64);
19773 assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
19774 assert!(!same_v2_head(&head, &same_view_new_control));
19775 }
19776
19777 #[test]
19778 fn verified_baseline_reuse_requires_exact_content_assets_view_and_authority() {
19779 let mut head = scoped_test_head(&"a".repeat(64));
19780 head.control_revision = "b".repeat(64);
19781 head.pointer = Some(V2PointerBody {
19782 v: 2,
19783 brain: TEST_BRAIN_ID.to_string(),
19784 seq: 7,
19785 commit_hash: "c".repeat(64),
19786 feed_hash: "d".repeat(64),
19787 content_root: Some("e".repeat(64)),
19788 asset_root: Some("f".repeat(64)),
19789 materializer: "dbmd-projection-v1".to_string(),
19790 signer_epoch: 1,
19791 control_revision: head.control_revision.clone(),
19792 backup_preparation: "0".repeat(64),
19793 prior_pointer_hash: Some("1".repeat(64)),
19794 signed_at: "2026-08-24T00:00:00.000Z".to_string(),
19795 });
19796 let mut baseline = scoped_test_baseline(&head.view_revision);
19797 baseline.head_seq = Some(7);
19798 baseline.commit_hash = Some("c".repeat(64));
19799 baseline.content_root = Some("e".repeat(64));
19800 baseline.asset_root = Some("f".repeat(64));
19801 baseline.control_revision = Some(head.control_revision.clone());
19802 assert!(v2_baseline_matches_head(&head, &baseline));
19803
19804 let mut changed = baseline.clone();
19805 changed.head_seq = Some(8);
19806 assert!(!v2_baseline_matches_head(&head, &changed));
19807 let mut changed = baseline.clone();
19808 changed.commit_hash = Some("2".repeat(64));
19809 assert!(!v2_baseline_matches_head(&head, &changed));
19810 let mut changed = baseline.clone();
19811 changed.content_root = Some("3".repeat(64));
19812 assert!(!v2_baseline_matches_head(&head, &changed));
19813 let mut changed = baseline.clone();
19814 changed.asset_root = Some("4".repeat(64));
19815 assert!(!v2_baseline_matches_head(&head, &changed));
19816 let mut changed = baseline.clone();
19817 changed.view_revision = Some("5".repeat(64));
19818 assert!(!v2_baseline_matches_head(&head, &changed));
19819 let mut changed = baseline.clone();
19820 changed.control_revision = Some("6".repeat(64));
19821 assert!(!v2_baseline_matches_head(&head, &changed));
19822
19823 let mut changed_head = head.clone();
19824 changed_head.view_kind = "full".to_string();
19825 assert!(!v2_baseline_matches_head(&changed_head, &baseline));
19826 }
19827
19828 #[test]
19829 fn legacy_baseline_without_authority_revision_refreshes_before_reuse() {
19830 let sandbox = tempfile::tempdir().unwrap();
19831 let cfg = test_hub_config(
19832 "https://hub.example".to_string(),
19833 sandbox.path().to_path_buf(),
19834 );
19835 let head = scoped_test_head(&"a".repeat(64));
19836 let baseline = scoped_test_baseline(&head.view_revision);
19837 let mut encoded = serde_json::to_value(&baseline).unwrap();
19838 encoded.as_object_mut().unwrap().remove("control_revision");
19839 let parsed =
19840 parse_v2_baseline(&cfg, TEST_BRAIN_ID, &serde_json::to_vec(&encoded).unwrap()).unwrap();
19841 assert!(parsed.control_revision.is_none());
19842 assert!(!v2_baseline_matches_head(&head, &parsed));
19843 }
19844
19845 #[test]
19846 fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
19847 let scoped = scoped_test_head(&"a".repeat(64));
19848 let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
19849 assert!(matches!(
19850 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
19851 Err(LinkError::ScopedProjectionModified)
19852 ));
19853
19854 let mut full = scoped.clone();
19855 full.view_kind = "full".to_string();
19856 let mut full_baseline = scoped_baseline.clone();
19857 full_baseline.view_kind = Some("full".to_string());
19858 full_baseline.projection_sha256 = None;
19859 assert!(matches!(
19860 ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
19861 Err(LinkError::InvalidPack { .. })
19862 ));
19863
19864 assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
19865 assert!(
19866 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
19867 );
19868 }
19869
19870 #[test]
19871 fn scoped_view_metadata_is_explicitly_non_authoritative() {
19872 let head = scoped_test_head(&"a".repeat(64));
19873 let value: Value =
19874 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
19875 assert_eq!(value["kind"], "link.md-scoped-view");
19876 assert_eq!(value["authoritative"], false);
19877 assert_eq!(value["visible_files"], 7);
19878 assert_eq!(value["brain"], TEST_BRAIN_ID);
19879 }
19880
19881 #[test]
19882 fn local_scoped_marker_requires_the_exact_generated_projection() {
19883 let directory = tempfile::tempdir().unwrap();
19884 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
19885 std::fs::write(
19886 directory.path().join("DB.md"),
19887 scoped_projection_bytes(TEST_BRAIN_ID),
19888 )
19889 .unwrap();
19890 let head = scoped_test_head(&"a".repeat(64));
19891 std::fs::write(
19892 directory.path().join(".dbmd/view.json"),
19893 scoped_view_metadata(&head, 0).unwrap(),
19894 )
19895 .unwrap();
19896 let store = Store::open_strict(directory.path()).unwrap();
19897 assert!(has_verified_local_scoped_view(&store));
19898
19899 std::fs::write(
19900 directory.path().join("DB.md"),
19901 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
19902 )
19903 .unwrap();
19904 let altered = Store::open_strict(directory.path()).unwrap();
19905 assert!(!has_verified_local_scoped_view(&altered));
19906 }
19907
19908 fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
19909 use ring::signature::KeyPair as _;
19910
19911 let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
19912 let rng = ring::rand::SystemRandom::new();
19913 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
19914 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
19915 let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
19916 let public_key = URL_SAFE_NO_PAD.encode(&public_der);
19917 let fingerprint = format!("{:x}", Sha256::digest(&public_der));
19918 let blob = b"new";
19919 let blob_hash = content_sha256(blob);
19920 let changes = json!({
19921 "mutation_id": "sync:proposal-fixture",
19922 "operations": [{
19923 "blob": blob_hash,
19924 "bytes": blob.len(),
19925 "expected": null,
19926 "op": "put",
19927 "path": "records/new.md",
19928 }],
19929 "reason": "fixture",
19930 "v": 2,
19931 });
19932 let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
19933 let changes_base64 = STANDARD.encode(&changes_bytes);
19934 let descriptor = json!({
19935 "base": null,
19936 "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
19937 "changes_base64": changes_base64,
19938 "rebase": "strict",
19939 "v": 2,
19940 });
19941 let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
19942 let payload_hash = "b".repeat(64);
19943 let submitted_at = "2026-08-19T12:00:00.000Z";
19944 let claim = json!({
19945 "actor_root": {
19946 "actor_class": "foreign_key",
19947 "credential": "ed25519:fixture",
19948 "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
19949 "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
19950 "principal": "key:fixture",
19951 "role": null,
19952 },
19953 "brain": TEST_BRAIN_ID,
19954 "clear_sha256": clear_hash,
19955 "control_revision": "c".repeat(64),
19956 "mutation_id": "sync:proposal-fixture",
19957 "payload_sha256": payload_hash,
19958 "proposal_id": proposal_id,
19959 "submitted_at": submitted_at,
19960 "v": 2,
19961 });
19962 let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
19963 let envelope = json!({
19964 "claim": claim,
19965 "fingerprint": fingerprint,
19966 "public_key": public_key,
19967 "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
19968 });
19969 let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
19970 let submission_hash =
19971 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
19972 let mut head = scoped_test_head(&"c".repeat(64));
19973 head.view_kind = "full".to_string();
19974 head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
19975 let value = json!({
19976 "proposal": {
19977 "base": null,
19978 "blobs": [{
19979 "bytes": blob.len(),
19980 "endpoint": format!(
19981 "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
19982 ),
19983 "sha256": blob_hash,
19984 }],
19985 "changes_base64": changes_base64,
19986 "clear_sha256": clear_hash,
19987 "expires_at": "2026-08-26T12:00:00.000Z",
19988 "id": proposal_id,
19989 "payload_sha256": payload_hash,
19990 "proposer": { "class": "foreign_key" },
19991 "rebase": "strict",
19992 "state": "pending",
19993 "submission_claim_base64": STANDARD.encode(envelope_bytes),
19994 "submission_claim_sha256": submission_hash,
19995 "submitted_at": submitted_at,
19996 },
19997 "v": 2,
19998 });
19999 (head, proposal_id, value)
20000 }
20001
20002 #[test]
20003 fn v2_proposal_verifier_accepts_exact_signed_payload() {
20004 let (head, proposal_id, value) = signed_proposal_fixture();
20005 let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
20006 assert_eq!(verified.blobs.len(), 1);
20007 assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
20008 }
20009
20010 #[test]
20011 fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
20012 let (head, proposal_id, value) = signed_proposal_fixture();
20013
20014 let mut changed = value.clone();
20015 changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
20016 assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
20017
20018 let mut redirected = value.clone();
20019 redirected["proposal"]["blobs"][0]["endpoint"] =
20020 Value::String("https://attacker.example/blob".to_string());
20021 assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
20022
20023 let mut forged = value;
20024 let encoded = forged["proposal"]["submission_claim_base64"]
20025 .as_str()
20026 .unwrap();
20027 let mut envelope: Value =
20028 serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
20029 envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
20030 let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
20031 forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
20032 forged["proposal"]["submission_claim_sha256"] = Value::String(
20033 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
20034 );
20035 assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
20036 }
20037
20038 #[cfg(unix)]
20039 #[test]
20040 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
20041 let sandbox = tempfile::tempdir().unwrap();
20042 let destination = sandbox.path().join("brain");
20043 let entries = vec![
20044 (
20045 "DB.md".to_string(),
20046 scoped_projection_bytes(TEST_BRAIN_ID),
20047 ),
20048 (
20049 "records/contacts/a.md".to_string(),
20050 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
20051 .to_vec(),
20052 ),
20053 ];
20054 install_pulled_delta(&destination, &entries, &[], true).unwrap();
20055 assert!(destination.join("index.md").is_file());
20056 assert!(destination.join("records/index.md").is_file());
20057 assert!(destination.join("records/contacts/index.md").is_file());
20058 assert!(destination.join("records/contacts/index.jsonl").is_file());
20059 }
20060
20061 #[cfg(unix)]
20062 #[test]
20063 fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
20064 let sandbox = tempfile::tempdir().unwrap();
20065 let destination = sandbox.path().join("brain");
20066 let cache = sandbox.path().join("cache");
20067 std::fs::create_dir(&cache).unwrap();
20068 let db = scoped_projection_bytes(TEST_BRAIN_ID);
20069 let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
20070 let db_source = cache.join("db");
20071 let shared_source = cache.join("shared");
20072 crate::fsx::write_atomic(&db_source, &db).unwrap();
20073 crate::fsx::write_atomic(&shared_source, shared).unwrap();
20074 let mut entries = vec![V2StagedFile {
20075 path: "DB.md".to_string(),
20076 source: db_source,
20077 sha256: content_sha256(&db),
20078 bytes: db.len() as u64,
20079 }];
20080 for index in 0..512 {
20081 entries.push(V2StagedFile {
20082 path: format!("records/items/{index:05}.md"),
20083 source: shared_source.clone(),
20084 sha256: content_sha256(shared),
20085 bytes: shared.len() as u64,
20086 });
20087 }
20088 install_pulled_delta_sources(
20089 &destination,
20090 &entries,
20091 &[],
20092 false,
20093 None,
20094 &scoped_test_head(&"c".repeat(64)),
20095 )
20096 .unwrap();
20097 assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
20098 for index in 0..512 {
20099 assert_eq!(
20100 std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
20101 shared
20102 );
20103 }
20104 assert!(
20105 std::fs::read_dir(sandbox.path())
20106 .unwrap()
20107 .all(|entry| !entry
20108 .unwrap()
20109 .file_name()
20110 .to_string_lossy()
20111 .contains("pull-stage")),
20112 "the private stage must be atomically installed or removed"
20113 );
20114 }
20115
20116 #[cfg(unix)]
20117 #[test]
20118 fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
20119 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
20120
20121 let sandbox = tempfile::tempdir().unwrap();
20122 let root = sandbox.path().join("brain");
20123 std::fs::create_dir_all(root.join("records/items")).unwrap();
20124 let db = scoped_projection_bytes(TEST_BRAIN_ID);
20125 let old = b"---\ntype: note\n---\n\nold\n";
20126 let new = b"---\ntype: note\n---\n\nnew\n";
20127 let removed = b"---\ntype: note\n---\n\nremove me\n";
20128 std::fs::write(root.join("DB.md"), &db).unwrap();
20129 std::fs::write(root.join("records/items/change.md"), old).unwrap();
20130 std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
20131 for index in 0..512 {
20132 std::fs::write(
20133 root.join(format!("records/items/untouched-{index:04}.md")),
20134 old,
20135 )
20136 .unwrap();
20137 }
20138 let untouched = root.join("records/items/untouched-0256.md");
20139 let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
20140 let source = sandbox.path().join("changed-source");
20141 crate::fsx::write_atomic(&source, new).unwrap();
20142 let same_source = sandbox.path().join("unchanged-source");
20143 crate::fsx::write_atomic(&same_source, old).unwrap();
20144 let same_entry = V2StagedFile {
20145 path: "records/items/change.md".to_string(),
20146 source: same_source,
20147 sha256: content_sha256(old),
20148 bytes: old.len() as u64,
20149 };
20150 let entry = V2StagedFile {
20151 path: "records/items/change.md".to_string(),
20152 source,
20153 sha256: content_sha256(new),
20154 bytes: new.len() as u64,
20155 };
20156 let head = scoped_test_head(&"c".repeat(64));
20157
20158 install_established_v2_delta(
20162 Store::open_strict(&root).unwrap(),
20163 &[same_entry],
20164 &["records/items/already-absent.md".to_string()],
20165 true,
20166 None,
20167 &head,
20168 )
20169 .unwrap();
20170 assert_eq!(
20171 std::fs::metadata(&untouched).unwrap().ino(),
20172 untouched_inode
20173 );
20174 assert!(!root.join(V2_PULL_JOURNAL).exists());
20175
20176 install_established_v2_delta(
20177 Store::open_strict(&root).unwrap(),
20178 &[entry],
20179 &["records/items/delete.md".to_string()],
20180 false,
20181 None,
20182 &head,
20183 )
20184 .unwrap();
20185 assert_eq!(
20186 std::fs::read(root.join("records/items/change.md")).unwrap(),
20187 new
20188 );
20189 assert!(!root.join("records/items/delete.md").exists());
20190 assert_eq!(
20191 std::fs::metadata(&untouched).unwrap().ino(),
20192 untouched_inode
20193 );
20194 assert!(root.join(V2_PULL_JOURNAL).is_file());
20195 assert_eq!(
20196 std::fs::metadata(root.join(V2_PULL_JOURNAL))
20197 .unwrap()
20198 .permissions()
20199 .mode()
20200 & 0o777,
20201 0o600
20202 );
20203 let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
20204 .unwrap()
20205 .unwrap();
20206 assert_eq!(
20207 std::fs::metadata(root.join(&journal.backup_dir))
20208 .unwrap()
20209 .permissions()
20210 .mode()
20211 & 0o777,
20212 0o700
20213 );
20214 for entry in &journal.entries {
20215 if let Some(backup) = &entry.backup {
20216 assert_eq!(
20217 std::fs::metadata(root.join(&journal.backup_dir).join(backup))
20218 .unwrap()
20219 .permissions()
20220 .mode()
20221 & 0o777,
20222 0o600
20223 );
20224 }
20225 }
20226
20227 let cfg = test_hub_config(
20228 "https://example.test".to_string(),
20229 sandbox.path().join("state"),
20230 );
20231 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20232 assert_eq!(
20233 std::fs::read(root.join("records/items/change.md")).unwrap(),
20234 old
20235 );
20236 assert_eq!(
20237 std::fs::read(root.join("records/items/delete.md")).unwrap(),
20238 removed
20239 );
20240 assert_eq!(
20241 std::fs::metadata(&untouched).unwrap().ino(),
20242 untouched_inode
20243 );
20244 assert!(!root.join(V2_PULL_JOURNAL).exists());
20245 }
20246
20247 #[test]
20248 fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
20249 let body = b"bounded bytes";
20250 let path = "records/example.md".to_string();
20251 let file = V2BaselineFile {
20252 sha256: content_sha256(body),
20253 bytes: body.len() as u64,
20254 proof: None,
20255 };
20256 let header = serde_json::to_vec(&json!({
20257 "bytes": body.len(),
20258 "path": path,
20259 "sha256": file.sha256,
20260 "v": 2,
20261 }))
20262 .unwrap();
20263 let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
20264 stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
20265 stream.extend_from_slice(&header);
20266 stream.extend_from_slice(body);
20267 stream.extend_from_slice(&0_u32.to_be_bytes());
20268 let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
20269 assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
20270
20271 let mut tampered = stream.clone();
20272 let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
20273 tampered[body_offset] ^= 1;
20274 assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
20275
20276 let mut trailing = stream;
20277 trailing.push(0);
20278 assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
20279 }
20280
20281 #[test]
20282 fn first_checkout_resolution_does_not_recreate_the_same_conflict() {
20283 let path = "records/value.md".to_string();
20284 let mut local = std::collections::BTreeMap::new();
20285 local.insert(path.clone(), (content_sha256(b"local"), 5));
20286 let mut remote = std::collections::BTreeMap::new();
20287 remote.insert(
20288 path.clone(),
20289 V2BaselineFile {
20290 sha256: content_sha256(b"remote"),
20291 bytes: 6,
20292 proof: None,
20293 },
20294 );
20295
20296 assert_eq!(
20297 v2_initial_content_conflicts(&local, &remote, false),
20298 vec![path]
20299 );
20300 assert!(v2_initial_content_conflicts(&local, &remote, true).is_empty());
20301
20302 let mut resolution = std::collections::BTreeMap::new();
20303 resolution.insert(
20304 "records/value.md".to_string(),
20305 V2ResolutionOverride {
20306 expected_remote: Some(content_sha256(b"remote")),
20307 selected_local: Some(content_sha256(b"local")),
20308 },
20309 );
20310 assert!(v2_resolution_allows_path(
20311 Some(&resolution),
20312 "records/value.md",
20313 true
20314 ));
20315 assert!(v2_resolution_allows_path(
20316 Some(&resolution),
20317 "records/new-target.md",
20318 false
20319 ));
20320 assert!(!v2_resolution_allows_path(
20321 Some(&resolution),
20322 "records/unreviewed-remote.md",
20323 true
20324 ));
20325 }
20326
20327 #[test]
20328 fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
20329 let sandbox = tempfile::TempDir::new().unwrap();
20330 let root = sandbox.path().join("brain");
20331 std::fs::create_dir_all(&root).unwrap();
20332 std::fs::write(
20333 root.join("DB.md"),
20334 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20335 )
20336 .unwrap();
20337 let store = Store::open_strict(&root).unwrap();
20338 let incomplete = crate::ulid::mint();
20339 store
20340 .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
20341 .unwrap();
20342 let expired = crate::ulid::mint();
20343 store
20344 .create_dir_all(&v2_conflict_relative(&expired, "files"))
20345 .unwrap();
20346 let plan = V2ConflictPlan {
20347 v: 2,
20348 class: "content_resolution_required".to_string(),
20349 bundle: expired.clone(),
20350 brain: TEST_BRAIN_ID.to_string(),
20351 origin: "https://example.test".to_string(),
20352 created_unix: 0,
20353 expires_unix: 0,
20354 base_seq: None,
20355 base_commit: None,
20356 remote_seq: 0,
20357 remote_commit: None,
20358 remote_content_root: None,
20359 view_kind: "full".to_string(),
20360 view_revision: "a".repeat(64),
20361 files: vec![V2ConflictFile {
20362 path: "records/value.md".to_string(),
20363 base: V2ConflictCoordinate {
20364 sha256: None,
20365 bytes: None,
20366 file: None,
20367 },
20368 local: V2ConflictCoordinate {
20369 sha256: None,
20370 bytes: None,
20371 file: None,
20372 },
20373 remote: V2ConflictCoordinate {
20374 sha256: None,
20375 bytes: None,
20376 file: None,
20377 },
20378 }],
20379 };
20380 let mut bytes = serde_json::to_vec(&plan).unwrap();
20381 bytes.push(b'\n');
20382 store
20383 .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
20384 .unwrap();
20385
20386 let listed = sync_conflicts(&root, false, false).unwrap();
20387 assert_eq!(listed["bundles"], 2);
20388 assert_eq!(listed["pruned"], 0);
20389 let pruned = sync_conflicts(&root, true, false).unwrap();
20390 assert_eq!(pruned["bundles"], 0);
20391 assert_eq!(pruned["pruned"], 2);
20392 assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
20393 assert!(!root.join(".dbmd/conflicts").join(expired).exists());
20394 }
20395
20396 #[test]
20397 fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
20398 let sandbox = tempfile::TempDir::new().unwrap();
20399 let root = sandbox.path().join("brain");
20400 std::fs::create_dir_all(&root).unwrap();
20401 std::fs::write(
20402 root.join("DB.md"),
20403 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20404 )
20405 .unwrap();
20406 let store = Store::open_strict(&root).unwrap();
20407 let bundle = crate::ulid::mint();
20408 store
20409 .create_dir_all(&v2_conflict_relative(&bundle, "files"))
20410 .unwrap();
20411 store
20412 .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
20413 .unwrap();
20414
20415 assert!(sync_conflicts(&root, true, false).is_err());
20416 assert!(sync_conflicts(&root, false, true).is_err());
20417 let pruned = sync_conflicts(&root, true, true).unwrap();
20418 assert_eq!(pruned["pruned"], 1);
20419 assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
20420 }
20421
20422 #[test]
20423 fn ready_pull_journal_rolls_back_exact_preimages() {
20424 let sandbox = tempfile::TempDir::new().unwrap();
20425 let root = sandbox.path().join("brain");
20426 std::fs::create_dir_all(root.join("records")).unwrap();
20427 std::fs::write(
20428 root.join("DB.md"),
20429 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20430 )
20431 .unwrap();
20432 let path = "records/value.md";
20433 let old = b"---\ntype: note\n---\n\nold\n";
20434 let new = b"---\ntype: note\n---\n\nnew\n";
20435 std::fs::write(root.join(path), old).unwrap();
20436 let store = Store::open_strict(&root).unwrap();
20437 let bundle = crate::ulid::mint();
20438 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20439 store
20440 .create_private_dir_all(Path::new(&backup_dir))
20441 .unwrap();
20442 store
20443 .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
20444 .unwrap();
20445 let journal = V2PullJournal {
20446 v: 1,
20447 phase: V2PullPhase::Ready,
20448 brain: TEST_BRAIN_ID.to_string(),
20449 previous: V2PullCoordinate {
20450 head_seq: None,
20451 commit_hash: None,
20452 view_kind: None,
20453 view_revision: None,
20454 },
20455 next: V2PullCoordinate {
20456 head_seq: Some(2),
20457 commit_hash: Some("c".repeat(64)),
20458 view_kind: Some("full".to_string()),
20459 view_revision: Some("d".repeat(64)),
20460 },
20461 backup_dir: backup_dir.clone(),
20462 entries: vec![V2PullJournalEntry {
20463 path: path.to_string(),
20464 old: Some(V2PullFileCoordinate {
20465 sha256: content_sha256(old),
20466 bytes: old.len() as u64,
20467 }),
20468 new: Some(V2PullFileCoordinate {
20469 sha256: content_sha256(new),
20470 bytes: new.len() as u64,
20471 }),
20472 backup: Some("00000000".to_string()),
20473 }],
20474 };
20475 validate_v2_pull_journal(&journal).unwrap();
20476 store
20477 .write_private_atomic_new(
20478 Path::new(V2_PULL_JOURNAL),
20479 &v2_pull_journal_bytes(&journal).unwrap(),
20480 )
20481 .unwrap();
20482 store.write_atomic(Path::new(path), new).unwrap();
20483
20484 let cfg = test_hub_config(
20485 "https://example.test".to_string(),
20486 sandbox.path().join("state"),
20487 );
20488 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20489 assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
20490 assert!(!root.join(V2_PULL_JOURNAL).exists());
20491 assert!(!root.join(backup_dir).exists());
20492 }
20493
20494 #[test]
20495 fn preparing_pull_journal_discards_only_private_staging() {
20496 let sandbox = tempfile::TempDir::new().unwrap();
20497 let root = sandbox.path().join("brain");
20498 std::fs::create_dir_all(root.join(".dbmd")).unwrap();
20499 std::fs::write(
20500 root.join("DB.md"),
20501 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20502 )
20503 .unwrap();
20504 let store = Store::open_strict(&root).unwrap();
20505 let bundle = crate::ulid::mint();
20506 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20507 store
20508 .create_private_dir_all(Path::new(&backup_dir))
20509 .unwrap();
20510 let journal = V2PullJournal {
20511 v: 1,
20512 phase: V2PullPhase::Preparing,
20513 brain: TEST_BRAIN_ID.to_string(),
20514 previous: V2PullCoordinate {
20515 head_seq: None,
20516 commit_hash: None,
20517 view_kind: None,
20518 view_revision: None,
20519 },
20520 next: V2PullCoordinate {
20521 head_seq: Some(1),
20522 commit_hash: Some("a".repeat(64)),
20523 view_kind: Some("full".to_string()),
20524 view_revision: Some("b".repeat(64)),
20525 },
20526 backup_dir: backup_dir.clone(),
20527 entries: vec![V2PullJournalEntry {
20528 path: "records/new.md".to_string(),
20529 old: None,
20530 new: Some(V2PullFileCoordinate {
20531 sha256: "c".repeat(64),
20532 bytes: 1,
20533 }),
20534 backup: None,
20535 }],
20536 };
20537 store
20538 .write_private_atomic_new(
20539 Path::new(V2_PULL_JOURNAL),
20540 &v2_pull_journal_bytes(&journal).unwrap(),
20541 )
20542 .unwrap();
20543 let cfg = test_hub_config(
20544 "https://example.test".to_string(),
20545 sandbox.path().join("state"),
20546 );
20547
20548 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20549
20550 assert!(root.join("DB.md").is_file());
20551 assert!(!root.join(V2_PULL_JOURNAL).exists());
20552 assert!(!root.join(backup_dir).exists());
20553 }
20554
20555 #[test]
20556 fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
20557 let sandbox = tempfile::TempDir::new().unwrap();
20558 let root = sandbox.path().join("brain");
20559 std::fs::create_dir_all(root.join("records")).unwrap();
20560 std::fs::write(
20561 root.join("DB.md"),
20562 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20563 )
20564 .unwrap();
20565 let new = b"---\ntype: note\n---\n\nnew\n";
20566 std::fs::write(root.join("records/value.md"), new).unwrap();
20567 let store = Store::open_strict(&root).unwrap();
20568 let bundle = crate::ulid::mint();
20569 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20570 store
20571 .create_private_dir_all(Path::new(&backup_dir))
20572 .unwrap();
20573 let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
20574 store.create_private_dir_all(Path::new(&orphan)).unwrap();
20575 let next = V2PullCoordinate {
20576 head_seq: Some(2),
20577 commit_hash: Some("c".repeat(64)),
20578 view_kind: Some("full".to_string()),
20579 view_revision: Some("d".repeat(64)),
20580 };
20581 let journal = V2PullJournal {
20582 v: 1,
20583 phase: V2PullPhase::Ready,
20584 brain: TEST_BRAIN_ID.to_string(),
20585 previous: V2PullCoordinate {
20586 head_seq: Some(1),
20587 commit_hash: Some("a".repeat(64)),
20588 view_kind: Some("full".to_string()),
20589 view_revision: Some("b".repeat(64)),
20590 },
20591 next: next.clone(),
20592 backup_dir: backup_dir.clone(),
20593 entries: vec![V2PullJournalEntry {
20594 path: "records/value.md".to_string(),
20595 old: Some(V2PullFileCoordinate {
20596 sha256: "e".repeat(64),
20597 bytes: new.len() as u64,
20598 }),
20599 new: Some(V2PullFileCoordinate {
20600 sha256: content_sha256(new),
20601 bytes: new.len() as u64,
20602 }),
20603 backup: Some("00000000".to_string()),
20604 }],
20605 };
20606 store
20607 .write_private_atomic_new(
20608 Path::new(V2_PULL_JOURNAL),
20609 &v2_pull_journal_bytes(&journal).unwrap(),
20610 )
20611 .unwrap();
20612 let cfg = test_hub_config(
20613 "https://example.test".to_string(),
20614 sandbox.path().join("state"),
20615 );
20616 save_v2_baseline(
20617 &cfg,
20618 TEST_BRAIN_ID,
20619 &root,
20620 &V2SyncBaseline {
20621 v: 2,
20622 origin: "https://example.test".to_string(),
20623 brain: TEST_BRAIN_ID.to_string(),
20624 checkout_id: Some("c".repeat(64)),
20625 head_seq: next.head_seq,
20626 commit_hash: next.commit_hash.clone(),
20627 content_root: Some("f".repeat(64)),
20628 asset_root: None,
20629 assets: Default::default(),
20630 view_kind: next.view_kind.clone(),
20631 view_revision: next.view_revision.clone(),
20632 control_revision: Some("d".repeat(64)),
20633 projection_sha256: None,
20634 files: Default::default(),
20635 scan_cache: Default::default(),
20636 local_policy_digest: None,
20637 local_eligibility: Default::default(),
20638 remote_copy_remains: Default::default(),
20639 },
20640 )
20641 .unwrap();
20642
20643 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20644
20645 assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
20646 assert!(!root.join(V2_PULL_JOURNAL).exists());
20647 assert!(!root.join(backup_dir).exists());
20648 assert!(!root.join(orphan).exists());
20649 }
20650
20651 #[test]
20652 fn only_typed_validation_projection_lag_retries_v2_head() {
20653 let typed = HubResponse {
20654 status: 422,
20655 body: Some(json!({ "code": "validation_index_catching_up" })),
20656 };
20657 let nested = HubResponse {
20658 status: 422,
20659 body: Some(json!({
20660 "details": { "code": "validation_index_catching_up" }
20661 })),
20662 };
20663 let unrelated = HubResponse {
20664 status: 422,
20665 body: Some(json!({ "code": "source_immutable" })),
20666 };
20667 let wrong_status = HubResponse {
20668 status: 403,
20669 body: typed.body.clone(),
20670 };
20671 assert!(v2_validation_catching_up(&typed));
20672 assert!(v2_validation_catching_up(&nested));
20673 assert!(!v2_validation_catching_up(&unrelated));
20674 assert!(!v2_validation_catching_up(&wrong_status));
20675 }
20676
20677 #[test]
20678 fn asset_resolution_is_limited_to_explicitly_resolved_wrappers() {
20679 let wrapper = "records/operational/package.md".to_string();
20680 let mut resolution = std::collections::BTreeMap::new();
20681 resolution.insert(
20682 wrapper.clone(),
20683 V2ResolutionOverride {
20684 expected_remote: Some("a".repeat(64)),
20685 selected_local: Some("b".repeat(64)),
20686 },
20687 );
20688 let local = crate::AssetRecord {
20689 path: "sources/package/object.blob".to_string(),
20690 sha256: "c".repeat(64),
20691 bytes: 1,
20692 media_type: "application/octet-stream".to_string(),
20693 wrappers: vec![wrapper.clone()],
20694 required: true,
20695 };
20696 assert!(v2_resolution_allows_asset(
20697 Some(&resolution),
20698 None,
20699 None,
20700 Some(&local),
20701 ));
20702 let unrelated = crate::AssetRecord {
20703 wrappers: vec!["records/unrelated.md".to_string()],
20704 ..local
20705 };
20706 assert!(!v2_resolution_allows_asset(
20707 Some(&resolution),
20708 None,
20709 None,
20710 Some(&unrelated),
20711 ));
20712 assert!(!v2_resolution_allows_asset(
20713 None,
20714 None,
20715 None,
20716 Some(&unrelated),
20717 ));
20718 }
20719}