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 non_markdown_asset_paths = crate::assets::read_manifest(store)
5520 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5521 .into_iter()
5522 .filter(|asset| !is_markdown_asset_path(&asset.path))
5523 .map(|asset| asset.path)
5524 .collect::<std::collections::BTreeSet<_>>();
5525 let mut result = std::collections::BTreeMap::new();
5526 let mut scan_cache = std::collections::BTreeMap::new();
5527 let mut eligibility = std::collections::BTreeMap::new();
5528 let mut withheld_links = Vec::<V2WithheldLink>::new();
5529 let mut total = 0_u64;
5530 let mut paths = vec![PathBuf::from("DB.md")];
5531 paths.extend(store.walk()?);
5532 for relative in paths {
5533 let path = relative.to_string_lossy().replace('\\', "/");
5534 if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5536 continue;
5537 }
5538 if non_markdown_asset_paths.contains(&path) {
5539 continue;
5540 }
5541 crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5542 path: error.to_string(),
5543 })?;
5544 let riding = !policy.keeps_home(&path);
5545 eligibility.insert(path.clone(), riding);
5546 if !riding {
5547 continue;
5548 }
5549 let remaining = MAX_STORE_BYTES.saturating_sub(total);
5550 let mut file = store.open_regular(&relative)?;
5551 let before = file.metadata()?;
5552 if before.len() > remaining {
5553 return Err(LinkError::PushTooLarge {
5554 detail: format!("more than {MAX_STORE_BYTES} uncompressed bytes"),
5555 });
5556 }
5557 let fingerprint = v2_scan_fingerprint(&before);
5558 if let (Some(fingerprint), Some(cached)) = (
5559 fingerprint.as_deref(),
5560 prior_cache.and_then(|cache| cache.get(&path)),
5561 ) {
5562 if cached.fingerprint == fingerprint && cached.bytes == before.len() {
5563 total = total
5564 .checked_add(cached.bytes)
5565 .ok_or_else(|| LinkError::PushTooLarge {
5566 detail: "v2 local byte count overflow".to_string(),
5567 })?;
5568 result.insert(path.clone(), (cached.sha256.clone(), cached.bytes));
5569 for target in &cached.withheld_targets {
5570 withheld_links.push(V2WithheldLink {
5571 source: path.clone(),
5572 target: target.clone(),
5573 });
5574 }
5575 scan_cache.insert(path, cached.clone());
5576 continue;
5577 }
5578 }
5579 let mut bytes = Vec::with_capacity(before.len().min(8 * 1024 * 1024) as usize);
5580 Read::by_ref(&mut file)
5581 .take(remaining.saturating_add(1))
5582 .read_to_end(&mut bytes)?;
5583 if bytes.len() as u64 > remaining {
5584 return Err(LinkError::PushTooLarge {
5585 detail: format!("more than {MAX_STORE_BYTES} uncompressed bytes"),
5586 });
5587 }
5588 total = total
5589 .checked_add(bytes.len() as u64)
5590 .ok_or_else(|| LinkError::PushTooLarge {
5591 detail: "v2 local byte count overflow".to_string(),
5592 })?;
5593 if total > MAX_STORE_BYTES {
5594 return Err(LinkError::PushTooLarge {
5595 detail: format!("{total} uncompressed bytes"),
5596 });
5597 }
5598 if std::str::from_utf8(&bytes).is_err() {
5599 return Err(LinkError::NotUtf8 { path });
5600 }
5601 let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5602 let targets = crate::store::extract_edge_targets(text)
5607 .into_iter()
5608 .map(|target| format!("{target}.md"))
5609 .filter(|target| policy.keeps_home(target))
5610 .collect::<Vec<_>>();
5611 for target in &targets {
5612 withheld_links.push(V2WithheldLink {
5613 source: path.clone(),
5614 target: target.clone(),
5615 });
5616 }
5617 let sha256 = content_sha256(&bytes);
5618 result.insert(path.clone(), (sha256.clone(), bytes.len() as u64));
5619 let after = file.metadata()?;
5620 if before.len() == bytes.len() as u64
5621 && v2_scan_fingerprint(&before) == v2_scan_fingerprint(&after)
5622 {
5623 if let Some(fingerprint) = v2_scan_fingerprint(&after) {
5624 scan_cache.insert(
5625 path,
5626 V2ScanCacheFile {
5627 fingerprint,
5628 sha256,
5629 bytes: bytes.len() as u64,
5630 withheld_targets: targets,
5631 },
5632 );
5633 }
5634 }
5635 }
5636 withheld_links.sort();
5637 withheld_links.dedup();
5638 Ok(V2LocalView {
5639 riding: result,
5640 scan_cache,
5641 eligibility,
5642 policy,
5643 withheld_links,
5644 })
5645}
5646
5647fn is_markdown_asset_path(path: &str) -> bool {
5648 path.to_ascii_lowercase().ends_with(".md")
5649}
5650
5651fn v2_content_put_operation_kind(
5652 path: &str,
5653 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
5654) -> &'static str {
5655 if local_assets.contains_key(path) && is_markdown_asset_path(path) {
5656 "put_asset_content"
5657 } else {
5658 "put"
5659 }
5660}
5661
5662fn v2_withdrawal_includes_content(
5663 path: &str,
5664 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
5665) -> bool {
5666 local_assets
5667 .get(path)
5668 .is_none_or(|asset| is_markdown_asset_path(&asset.path))
5669}
5670
5671fn verify_v2_markdown_asset_content_bindings(
5672 content: &std::collections::BTreeMap<String, V2BaselineFile>,
5673 assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
5674) -> LinkResult<()> {
5675 for (path, asset) in assets {
5676 if !is_markdown_asset_path(path) {
5677 continue;
5678 }
5679 let content_file = content.get(path);
5680 let exact = content_file
5681 .is_some_and(|file| file.sha256 == asset.blob_sha256 && file.bytes == asset.bytes);
5682 if (asset.disposition == "hosted" && !exact)
5683 || (asset.disposition == "withheld" && content_file.is_some())
5684 {
5685 return Err(invalid_feed(format!(
5686 "markdown asset `{path}` is not exactly bound across the signed content and asset views"
5687 )));
5688 }
5689 }
5690 Ok(())
5691}
5692
5693fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5694 v2_local_files_cached(store, None)
5695}
5696
5697#[derive(Debug, Clone, Deserialize)]
5698struct V2DownloadItem {
5699 path: String,
5700 sha256: String,
5701 bytes: u64,
5702 url: String,
5703 method: String,
5704}
5705
5706#[derive(Debug, Deserialize)]
5707struct V2DownloadWindow {
5708 v: u8,
5709 commit: String,
5710 downloads: Vec<V2DownloadItem>,
5711}
5712
5713#[derive(Debug, Deserialize)]
5714struct V2BulkStreamHeader {
5715 v: u8,
5716 path: String,
5717 sha256: String,
5718 bytes: u64,
5719}
5720
5721fn parse_v2_bulk_stream(
5722 bytes: &[u8],
5723 expected: &[(&String, &V2BaselineFile)],
5724) -> LinkResult<Vec<(String, Vec<u8>)>> {
5725 if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5726 return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5727 }
5728 let mut cursor = V2_BULK_STREAM_MAGIC.len();
5729 let mut result = Vec::with_capacity(expected.len());
5730 for (expected_path, expected_file) in expected {
5731 let length_bytes = bytes
5732 .get(cursor..cursor + 4)
5733 .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5734 cursor += 4;
5735 let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5736 if header_len == 0 || header_len > 4 * 1024 {
5737 return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5738 }
5739 let header_bytes = bytes
5740 .get(cursor..cursor + header_len)
5741 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5742 cursor += header_len;
5743 let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5744 .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5745 if header.v != 2
5746 || &header.path != *expected_path
5747 || header.sha256 != expected_file.sha256
5748 || header.bytes != expected_file.bytes
5749 || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5750 {
5751 return Err(invalid_feed(
5752 "v2 bulk stream frame differs from its proven manifest entry",
5753 ));
5754 }
5755 let body_len = usize::try_from(header.bytes)
5756 .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5757 let body = bytes
5758 .get(cursor..cursor + body_len)
5759 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5760 cursor += body_len;
5761 if content_sha256(body) != header.sha256 {
5762 return Err(invalid_feed(
5763 "v2 bulk stream file differs from its proven manifest entry",
5764 ));
5765 }
5766 result.push((header.path, body.to_vec()));
5767 }
5768 if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5769 return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5770 }
5771 cursor += 4;
5772 if cursor != bytes.len() {
5773 return Err(invalid_feed("v2 bulk stream carries trailing data"));
5774 }
5775 Ok(result)
5776}
5777
5778fn download_v2_bulk_stream(
5779 cfg: &HubConfig,
5780 brain: &str,
5781 pointer: &V2PointerBody,
5782 pending: &[(&String, &V2BaselineFile)],
5783) -> LinkResult<Vec<(String, Vec<u8>)>> {
5784 let claims = pending
5785 .iter()
5786 .map(|(path, file)| {
5787 Ok(json!({
5788 "path": path,
5789 "sha256": file.sha256,
5790 "bytes": file.bytes,
5791 "proof": file.proof.as_ref().ok_or_else(|| {
5792 invalid_feed("v2 manifest omitted a bulk-stream proof")
5793 })?,
5794 }))
5795 })
5796 .collect::<LinkResult<Vec<_>>>()?;
5797 let raw = request_raw_retryable_read(
5798 cfg,
5799 "POST",
5800 &format!("/api/hub/brains/{brain}/v2/stream"),
5801 Some(&json!({
5802 "commit": pointer.commit_hash,
5803 "files": claims,
5804 })),
5805 Auth::Required,
5806 V2_BULK_STREAM_RESPONSE_BYTES,
5807 )?;
5808 let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5809 parse_v2_bulk_stream(&body, pending)
5810}
5811
5812fn request_capped_retryable_read(
5813 cfg: &HubConfig,
5814 method: &str,
5815 path: &str,
5816 body: Option<&Value>,
5817 auth: Auth,
5818 max_response_bytes: u64,
5819) -> LinkResult<HubResponse> {
5820 let raw = request_raw_retryable_read(cfg, method, path, body, auth, max_response_bytes)?;
5821 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
5822 Ok(HubResponse {
5823 status: raw.status,
5824 body: parsed,
5825 })
5826}
5827
5828fn prepare_v2_downloads(
5829 cfg: &HubConfig,
5830 brain: &str,
5831 pointer: &V2PointerBody,
5832 pending: &[(&String, &V2BaselineFile)],
5833) -> LinkResult<Vec<V2DownloadItem>> {
5834 let mut result = Vec::with_capacity(pending.len());
5835 for chunk in pending.chunks(128) {
5836 let claims = chunk
5837 .iter()
5838 .map(|(path, file)| {
5839 Ok(json!({
5840 "path": path,
5841 "sha256": file.sha256,
5842 "bytes": file.bytes,
5843 "proof": file.proof.as_ref().ok_or_else(|| {
5844 invalid_feed("v2 manifest omitted a download proof")
5845 })?,
5846 }))
5847 })
5848 .collect::<LinkResult<Vec<_>>>()?;
5849 let value = ensure_ok(
5850 request_capped_retryable_read(
5851 cfg,
5852 "POST",
5853 &format!("/api/hub/brains/{brain}/v2/downloads"),
5854 Some(&json!({
5855 "commit": pointer.commit_hash,
5856 "files": claims,
5857 })),
5858 Auth::Required,
5859 MAX_FEED_RESPONSE_BYTES,
5860 )?,
5861 "prepare v2 blob downloads",
5862 )?;
5863 let window: V2DownloadWindow = serde_json::from_value(value)
5864 .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5865 if window.v != 2
5866 || window.commit != pointer.commit_hash
5867 || window.downloads.len() != chunk.len()
5868 {
5869 return Err(invalid_feed(
5870 "v2 download window is not bound to the requested files",
5871 ));
5872 }
5873 let mut by_path = window
5874 .downloads
5875 .into_iter()
5876 .map(|item| (item.path.clone(), item))
5877 .collect::<std::collections::BTreeMap<_, _>>();
5878 if by_path.len() != chunk.len() {
5879 return Err(invalid_feed("v2 download window repeats a path"));
5880 }
5881 for (path, file) in chunk {
5882 let item = by_path
5883 .remove(*path)
5884 .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5885 if item.method != "GET"
5886 || item.sha256 != file.sha256
5887 || item.bytes != file.bytes
5888 || item.url.is_empty()
5889 {
5890 return Err(invalid_feed(
5891 "v2 download capability differs from its proven file",
5892 ));
5893 }
5894 result.push(item);
5895 }
5896 }
5897 Ok(result)
5898}
5899
5900fn prepare_v2_asset_downloads(
5901 cfg: &HubConfig,
5902 brain: &str,
5903 pointer: &V2PointerBody,
5904 pending: &[(&String, &V2BaselineAsset)],
5905) -> LinkResult<Vec<V2DownloadItem>> {
5906 let mut result = Vec::with_capacity(pending.len());
5907 for chunk in pending.chunks(128) {
5908 let claims = chunk
5909 .iter()
5910 .map(|(path, asset)| {
5911 json!({
5912 "path": path,
5913 "sha256": asset.blob_sha256,
5914 "bytes": asset.bytes,
5915 "leaf_hash": asset.leaf_hash,
5916 })
5917 })
5918 .collect::<Vec<_>>();
5919 let value = ensure_ok(
5920 request_capped_retryable_read(
5921 cfg,
5922 "POST",
5923 &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5924 Some(&json!({
5925 "commit": pointer.commit_hash,
5926 "assets": claims,
5927 })),
5928 Auth::Required,
5929 MAX_FEED_RESPONSE_BYTES,
5930 )?,
5931 "prepare v2 asset downloads",
5932 )?;
5933 let window: V2DownloadWindow = serde_json::from_value(value)
5934 .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5935 if window.v != 2
5936 || window.commit != pointer.commit_hash
5937 || window.downloads.len() != chunk.len()
5938 {
5939 return Err(invalid_feed(
5940 "v2 asset download window is not bound to the requested assets",
5941 ));
5942 }
5943 let mut by_path = window
5944 .downloads
5945 .into_iter()
5946 .map(|item| (item.path.clone(), item))
5947 .collect::<std::collections::BTreeMap<_, _>>();
5948 if by_path.len() != chunk.len() {
5949 return Err(invalid_feed("v2 asset download window repeats a path"));
5950 }
5951 for (path, asset) in chunk {
5952 let item = by_path
5953 .remove(*path)
5954 .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5955 if item.method != "GET"
5956 || item.sha256 != asset.blob_sha256
5957 || item.bytes != asset.bytes
5958 || item.url.is_empty()
5959 {
5960 return Err(invalid_feed(
5961 "v2 asset download capability differs from its signed leaf",
5962 ));
5963 }
5964 result.push(item);
5965 }
5966 }
5967 Ok(result)
5968}
5969
5970#[cfg(any(unix, windows))]
5971fn stage_v2_asset_download_window(
5972 cfg: &HubConfig,
5973 brain: &str,
5974 pointer: &V2PointerBody,
5975 cache_dir: &Path,
5976 pending: &[(&String, &V2BaselineAsset)],
5977) -> LinkResult<Vec<V2StagedFile>> {
5978 if pending.is_empty() {
5979 return Ok(Vec::new());
5980 }
5981 if pending.len() > V2_DOWNLOAD_CAPABILITY_FILES {
5982 return Err(invalid_feed("v2 asset capability window is oversized"));
5983 }
5984
5985 let mut last_error = None;
5986 for retry_delay in V2_DOWNLOAD_CAPABILITY_BACKOFF_MS
5987 .iter()
5988 .copied()
5989 .map(Some)
5990 .chain(std::iter::once(None))
5991 {
5992 let downloads = prepare_v2_asset_downloads(cfg, brain, pointer, pending)?;
5997 let mut unique = std::collections::BTreeMap::<String, V2DownloadItem>::new();
5998 for item in downloads {
5999 match unique.get(&item.sha256) {
6000 Some(prior) if prior.bytes != item.bytes => {
6001 return Err(invalid_feed(
6002 "one v2 asset hash has conflicting byte lengths",
6003 ));
6004 }
6005 Some(_) => {}
6006 None => {
6007 unique.insert(item.sha256.clone(), item);
6008 }
6009 }
6010 }
6011 let downloads = unique.into_values().collect::<Vec<_>>();
6012 let next = std::sync::atomic::AtomicUsize::new(0);
6013 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6014 let mut results = std::iter::repeat_with(|| None)
6015 .take(downloads.len())
6016 .collect::<Vec<Option<LinkResult<PathBuf>>>>();
6017 std::thread::scope(|scope| {
6018 let (sender, receiver) = std::sync::mpsc::channel();
6019 for _ in 0..worker_count {
6020 let sender = sender.clone();
6021 let downloads = &downloads;
6022 let next = &next;
6023 scope.spawn(move || loop {
6024 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6025 let Some(item) = downloads.get(index) else {
6026 break;
6027 };
6028 let result = download_presigned_to_cache(
6029 cfg,
6030 &item.url,
6031 cache_dir,
6032 &item.sha256,
6033 item.bytes,
6034 );
6035 if sender.send((index, result)).is_err() {
6036 break;
6037 }
6038 });
6039 }
6040 drop(sender);
6041 for (index, result) in receiver {
6042 results[index] = Some(result);
6043 }
6044 });
6045
6046 let mut failed = None;
6047 for result in results {
6048 match result {
6049 Some(Ok(_)) => {}
6050 Some(Err(error)) if failed.is_none() => failed = Some(error),
6051 Some(Err(_)) => {}
6052 None if failed.is_none() => {
6053 failed = Some(LinkError::Transport {
6054 hub: cfg.hub.clone(),
6055 message: "a bounded v2 asset worker stopped before reporting its result"
6056 .to_string(),
6057 });
6058 }
6059 None => {}
6060 }
6061 }
6062 if let Some(error) = failed {
6063 last_error = Some(error);
6064 if let Some(milliseconds) = retry_delay {
6065 std::thread::sleep(std::time::Duration::from_millis(milliseconds));
6066 continue;
6067 }
6068 break;
6069 }
6070
6071 return pending
6072 .iter()
6073 .map(|(path, asset)| {
6074 let source = cache_dir.join(&asset.blob_sha256);
6075 if !cached_blob_is_exact(&source, &asset.blob_sha256, asset.bytes)? {
6076 return Err(invalid_feed(
6077 "v2 asset download cache omitted a proven blob",
6078 ));
6079 }
6080 Ok(V2StagedFile {
6081 path: (*path).clone(),
6082 source,
6083 sha256: asset.blob_sha256.clone(),
6084 bytes: asset.bytes,
6085 })
6086 })
6087 .collect();
6088 }
6089 Err(last_error
6090 .unwrap_or_else(|| invalid_feed("v2 asset capability window made no download attempt")))
6091}
6092
6093fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
6094 let bytes = get_presigned(cfg, &item.url)?;
6095 if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
6096 return Err(invalid_feed("v2 blob differs from its proven path entry"));
6097 }
6098 Ok(bytes)
6099}
6100
6101#[derive(Debug, Clone)]
6102struct V2StagedFile {
6103 path: String,
6104 source: PathBuf,
6105 sha256: String,
6106 bytes: u64,
6107}
6108
6109#[cfg(unix)]
6110fn v2_download_cache_dir(
6111 cfg: &HubConfig,
6112 brain: &str,
6113 pointer: &V2PointerBody,
6114) -> LinkResult<PathBuf> {
6115 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
6116}
6117
6118#[cfg(unix)]
6119fn v2_download_cache_dir_for(
6120 cfg: &HubConfig,
6121 brain: &str,
6122 transaction: &str,
6123) -> LinkResult<PathBuf> {
6124 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6125 return Err(invalid_feed("v2 download cache address is invalid"));
6126 }
6127 let path = cfg
6128 .state_dir
6129 .join("downloads")
6130 .join(brain)
6131 .join(transaction);
6132 let directory = open_or_create_dir_nofollow(&path)?;
6133 use std::os::fd::AsRawFd as _;
6134 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
6135 return Err(std::io::Error::last_os_error().into());
6136 }
6137 directory.sync_all()?;
6138 Ok(path)
6139}
6140
6141#[cfg(windows)]
6142fn v2_download_cache_dir(
6143 cfg: &HubConfig,
6144 brain: &str,
6145 pointer: &V2PointerBody,
6146) -> LinkResult<PathBuf> {
6147 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
6148}
6149
6150#[cfg(windows)]
6151fn v2_download_cache_dir_for(
6152 cfg: &HubConfig,
6153 brain: &str,
6154 transaction: &str,
6155) -> LinkResult<PathBuf> {
6156 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6157 return Err(invalid_feed("v2 download cache address is invalid"));
6158 }
6159 let path = cfg
6160 .state_dir
6161 .join("downloads")
6162 .join(brain)
6163 .join(transaction);
6164 crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
6165 crate::fsx::open_directory_nofollow(&path)?;
6166 Ok(path)
6167}
6168
6169#[cfg(unix)]
6170fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
6171 use std::os::fd::AsRawFd as _;
6172 let parent = cfg.state_dir.join("downloads").join(brain);
6173 let Ok(directory) = open_existing_dir_nofollow(&parent) else {
6174 return;
6175 };
6176 let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
6177 return;
6178 };
6179 let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
6180 let _ = directory.sync_all();
6181}
6182
6183#[cfg(windows)]
6184fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
6185 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6186 return;
6187 }
6188 let parent = cfg.state_dir.join("downloads").join(brain);
6189 let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
6190 return;
6191 };
6192 let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
6193}
6194
6195#[cfg(not(any(unix, windows)))]
6196fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
6197
6198#[cfg(not(any(unix, windows)))]
6199fn v2_download_cache_dir_for(
6200 _cfg: &HubConfig,
6201 _brain: &str,
6202 _transaction: &str,
6203) -> LinkResult<PathBuf> {
6204 Err(LinkError::UnsupportedPlatform {
6205 operation: "resumable v2 download staging",
6206 })
6207}
6208
6209#[cfg(any(unix, windows))]
6210fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
6211 let file = match crate::fsx::open_regular_nofollow(path) {
6212 Ok(file) => file,
6213 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6214 Err(error) => return Err(error.into()),
6215 };
6216 if file.metadata()?.len() != bytes {
6217 return Ok(false);
6218 }
6219 Ok(content_sha256_reader(file)? == sha256)
6220}
6221
6222#[cfg(any(unix, windows))]
6223fn cache_v2_blob_bytes(
6224 cache_dir: &Path,
6225 sha256: &str,
6226 expected_bytes: u64,
6227 bytes: &[u8],
6228) -> LinkResult<PathBuf> {
6229 if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
6230 return Err(invalid_feed("v2 cached blob differs from its declaration"));
6231 }
6232 let path = cache_dir.join(sha256);
6233 if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
6234 crate::fsx::write_atomic(&path, bytes)?;
6235 }
6236 Ok(path)
6237}
6238
6239#[cfg(not(any(unix, windows)))]
6240fn cache_v2_blob_bytes(
6241 _cache_dir: &Path,
6242 _sha256: &str,
6243 _expected_bytes: u64,
6244 _bytes: &[u8],
6245) -> LinkResult<PathBuf> {
6246 Err(LinkError::UnsupportedPlatform {
6247 operation: "resumable v2 download staging",
6248 })
6249}
6250
6251#[cfg(unix)]
6252fn download_presigned_to_cache(
6253 cfg: &HubConfig,
6254 url: &str,
6255 cache_dir: &Path,
6256 sha256: &str,
6257 expected_bytes: u64,
6258) -> LinkResult<PathBuf> {
6259 use std::os::fd::{AsRawFd as _, FromRawFd as _};
6260
6261 let target = cache_dir.join(sha256);
6262 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6263 return Ok(target);
6264 }
6265 let directory = open_existing_dir_nofollow(cache_dir)?;
6266 let mut nonce = [0_u8; 16];
6267 ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
6268 .map_err(|_| invalid_feed("could not mint a download cache name"))?;
6269 let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
6270 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
6271 let fd = unsafe {
6272 libc::openat(
6273 directory.as_raw_fd(),
6274 temp.as_ptr(),
6275 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6276 0o600,
6277 )
6278 };
6279 if fd < 0 {
6280 return Err(std::io::Error::last_os_error().into());
6281 }
6282 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
6283 let response = match presigned_agent(cfg, url)?.get(url).call() {
6284 Ok(response) => response,
6285 Err(ureq::Error::Status(_, response)) => {
6286 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6287 return Err(LinkError::Http {
6288 what: "v2 direct download",
6289 status: response.status(),
6290 message: "object store rejected the download".to_string(),
6291 code: None,
6292 details: None,
6293 });
6294 }
6295 Err(ureq::Error::Transport(error)) => {
6296 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6297 return Err(LinkError::Transport {
6298 hub: cfg.hub.clone(),
6299 message: error.to_string(),
6300 });
6301 }
6302 };
6303 let mut reader = response
6304 .into_reader()
6305 .take(expected_bytes.saturating_add(1));
6306 let mut digest = Sha256::new();
6307 let mut total = 0_u64;
6308 let mut buffer = [0_u8; 64 * 1024];
6309 let write_result = (|| -> LinkResult<()> {
6314 loop {
6315 let read = reader
6316 .read(&mut buffer)
6317 .map_err(|error| LinkError::Transport {
6318 hub: cfg.hub.clone(),
6319 message: error.to_string(),
6320 })?;
6321 if read == 0 {
6322 break;
6323 }
6324 total = total.saturating_add(read as u64);
6325 digest.update(&buffer[..read]);
6326 output.write_all(&buffer[..read])?;
6327 }
6328 output.sync_all().map_err(LinkError::from)
6329 })();
6330 if let Err(error) = write_result {
6331 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6332 return Err(error);
6333 }
6334 drop(output);
6335 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6336 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6337 return Err(invalid_feed(
6338 "v2 direct download failed integrity verification",
6339 ));
6340 }
6341 let target_name = c_name(sha256.as_bytes(), sha256)?;
6342 if unsafe {
6345 libc::renameat(
6346 directory.as_raw_fd(),
6347 temp.as_ptr(),
6348 directory.as_raw_fd(),
6349 target_name.as_ptr(),
6350 )
6351 } != 0
6352 {
6353 let error = std::io::Error::last_os_error();
6354 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6355 return Err(error.into());
6356 }
6357 directory.sync_all()?;
6358 Ok(target)
6359}
6360
6361#[cfg(windows)]
6362fn download_presigned_to_cache(
6363 cfg: &HubConfig,
6364 url: &str,
6365 cache_dir: &Path,
6366 sha256: &str,
6367 expected_bytes: u64,
6368) -> LinkResult<PathBuf> {
6369 use std::fs::OpenOptions;
6370
6371 let target = cache_dir.join(sha256);
6372 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6373 return Ok(target);
6374 }
6375 let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
6379 let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
6380 let mut output = OpenOptions::new()
6381 .write(true)
6382 .create_new(true)
6383 .open(&temp)?;
6384 let response = match presigned_agent(cfg, url)?.get(url).call() {
6385 Ok(response) => response,
6386 Err(ureq::Error::Status(_, response)) => {
6387 let _ = std::fs::remove_file(&temp);
6388 return Err(LinkError::Http {
6389 what: "v2 direct download",
6390 status: response.status(),
6391 message: "object store rejected the download".to_string(),
6392 code: None,
6393 details: None,
6394 });
6395 }
6396 Err(ureq::Error::Transport(error)) => {
6397 let _ = std::fs::remove_file(&temp);
6398 return Err(LinkError::Transport {
6399 hub: cfg.hub.clone(),
6400 message: error.to_string(),
6401 });
6402 }
6403 };
6404 let mut reader = response
6405 .into_reader()
6406 .take(expected_bytes.saturating_add(1));
6407 let mut digest = Sha256::new();
6408 let mut total = 0_u64;
6409 let mut buffer = [0_u8; 64 * 1024];
6410 let copied = (|| -> LinkResult<()> {
6412 loop {
6413 let read = reader
6414 .read(&mut buffer)
6415 .map_err(|error| LinkError::Transport {
6416 hub: cfg.hub.clone(),
6417 message: error.to_string(),
6418 })?;
6419 if read == 0 {
6420 break;
6421 }
6422 total = total.saturating_add(read as u64);
6423 digest.update(&buffer[..read]);
6424 output.write_all(&buffer[..read])?;
6425 }
6426 output.sync_all()?;
6427 Ok(())
6428 })();
6429 if let Err(error) = copied {
6430 let _ = std::fs::remove_file(&temp);
6431 return Err(error);
6432 }
6433 drop(output);
6434 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6435 let _ = std::fs::remove_file(&temp);
6436 return Err(invalid_feed(
6437 "v2 direct download failed integrity verification",
6438 ));
6439 }
6440 if target.exists() {
6441 std::fs::remove_file(&target)?;
6442 }
6443 if let Err(error) = std::fs::rename(&temp, &target) {
6444 let _ = std::fs::remove_file(&temp);
6445 return Err(error.into());
6446 }
6447 Ok(target)
6448}
6449
6450#[cfg(not(any(unix, windows)))]
6451fn download_presigned_to_cache(
6452 _cfg: &HubConfig,
6453 _url: &str,
6454 _cache_dir: &Path,
6455 _sha256: &str,
6456 _expected_bytes: u64,
6457) -> LinkResult<PathBuf> {
6458 Err(LinkError::UnsupportedPlatform {
6459 operation: "resumable v2 download staging",
6460 })
6461}
6462
6463fn download_v2_blobs(
6464 cfg: &HubConfig,
6465 brain: &str,
6466 pointer: &V2PointerBody,
6467 pending: Vec<(&String, &V2BaselineFile)>,
6468) -> LinkResult<Vec<(String, Vec<u8>)>> {
6469 if pending.is_empty() {
6470 return Ok(Vec::new());
6471 }
6472 let expected_order = pending
6473 .iter()
6474 .map(|(path, _)| (*path).clone())
6475 .collect::<Vec<_>>();
6476 let mut streamed = std::collections::BTreeMap::new();
6477 let mut direct = Vec::new();
6478 let mut window = Vec::new();
6479 let mut window_bytes = 0_u64;
6480 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6481 window_bytes: &mut u64,
6482 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
6483 -> LinkResult<()> {
6484 if window.is_empty() {
6485 return Ok(());
6486 }
6487 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
6488 if streamed.insert(path, bytes).is_some() {
6489 return Err(invalid_feed("v2 bulk streams repeated a path"));
6490 }
6491 }
6492 window.clear();
6493 *window_bytes = 0;
6494 Ok(())
6495 };
6496 for &(path, file) in &pending {
6497 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6498 flush(&mut window, &mut window_bytes, &mut streamed)?;
6499 direct.push((path, file));
6500 continue;
6501 }
6502 if window.len() == V2_BULK_STREAM_FILES
6503 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6504 {
6505 flush(&mut window, &mut window_bytes, &mut streamed)?;
6506 }
6507 window.push((path, file));
6508 window_bytes += file.bytes;
6509 }
6510 flush(&mut window, &mut window_bytes, &mut streamed)?;
6511
6512 let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
6513 let next = std::sync::atomic::AtomicUsize::new(0);
6514 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6515 let mut results = std::iter::repeat_with(|| None)
6516 .take(downloads.len())
6517 .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
6518 std::thread::scope(|scope| {
6519 let (sender, receiver) = std::sync::mpsc::channel();
6520 for _ in 0..worker_count {
6521 let sender = sender.clone();
6522 let downloads = &downloads;
6523 let next = &next;
6524 scope.spawn(move || loop {
6525 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6526 let Some(item) = downloads.get(index) else {
6527 break;
6528 };
6529 let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
6530 if sender.send((index, result)).is_err() {
6531 break;
6532 }
6533 });
6534 }
6535 drop(sender);
6536 for (index, result) in receiver {
6537 results[index] = Some(result);
6538 }
6539 });
6540 for result in results.into_iter().map(|result| {
6541 result.ok_or_else(|| LinkError::Transport {
6542 hub: cfg.hub.clone(),
6543 message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
6544 })?
6545 }) {
6546 let (path, bytes) = result?;
6547 if streamed.insert(path, bytes).is_some() {
6548 return Err(invalid_feed("v2 download lanes repeated a path"));
6549 }
6550 }
6551 expected_order
6552 .into_iter()
6553 .map(|path| {
6554 streamed
6555 .remove(&path)
6556 .map(|bytes| (path, bytes))
6557 .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
6558 })
6559 .collect()
6560}
6561
6562#[cfg(any(unix, windows))]
6566fn queue_v2_bulk_window<'a>(
6567 cache_dir: &Path,
6568 window: &mut Vec<(&'a String, &'a V2BaselineFile)>,
6569 window_bytes: &mut u64,
6570 staged: &mut std::collections::BTreeMap<String, V2StagedFile>,
6571 missing_windows: &mut Vec<Vec<(&'a String, &'a V2BaselineFile)>>,
6572) -> LinkResult<()> {
6573 if window.is_empty() {
6574 return Ok(());
6575 }
6576 let missing = window
6577 .iter()
6578 .filter_map(|(path, file)| {
6579 let target = cache_dir.join(&file.sha256);
6580 match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6581 Ok(true) => {
6582 staged.insert(
6583 (*path).clone(),
6584 V2StagedFile {
6585 path: (*path).clone(),
6586 source: target,
6587 sha256: file.sha256.clone(),
6588 bytes: file.bytes,
6589 },
6590 );
6591 None
6592 }
6593 Ok(false) => Some(Ok((*path, *file))),
6594 Err(error) => Some(Err(error)),
6595 }
6596 })
6597 .collect::<LinkResult<Vec<_>>>()?;
6598 if !missing.is_empty() {
6599 missing_windows.push(missing);
6600 }
6601 window.clear();
6602 *window_bytes = 0;
6603 Ok(())
6604}
6605
6606#[cfg(any(unix, windows))]
6607fn stage_v2_bulk_windows<'a>(
6608 cfg: &HubConfig,
6609 brain: &str,
6610 pointer: &V2PointerBody,
6611 cache_dir: &Path,
6612 windows: Vec<Vec<(&'a String, &'a V2BaselineFile)>>,
6613 staged: &mut std::collections::BTreeMap<String, V2StagedFile>,
6614) -> LinkResult<()> {
6615 let next = std::sync::atomic::AtomicUsize::new(0);
6616 let worker_count = windows.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6617 let mut first_error = None;
6618 std::thread::scope(|scope| {
6619 let (sender, receiver) = std::sync::mpsc::channel();
6620 for _ in 0..worker_count {
6621 let sender = sender.clone();
6622 let windows = &windows;
6623 let next = &next;
6624 scope.spawn(move || loop {
6625 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6626 let Some(window) = windows.get(index) else {
6627 break;
6628 };
6629 let result = download_v2_bulk_stream(cfg, brain, pointer, window);
6630 if sender.send((index, result)).is_err() {
6631 break;
6632 }
6633 });
6634 }
6635 drop(sender);
6636 for (index, result) in receiver {
6637 match result {
6638 Ok(files) => {
6639 for (path, bytes) in files {
6640 let Some(file) = windows[index].iter().find_map(|(expected_path, file)| {
6641 (*expected_path == &path).then_some(*file)
6642 }) else {
6643 first_error.get_or_insert_with(|| {
6644 invalid_feed("v2 stream returned an unrequested cache path")
6645 });
6646 continue;
6647 };
6648 match cache_v2_blob_bytes(cache_dir, &file.sha256, file.bytes, &bytes) {
6649 Ok(source) => {
6650 if staged
6651 .insert(
6652 path.clone(),
6653 V2StagedFile {
6654 path,
6655 source,
6656 sha256: file.sha256.clone(),
6657 bytes: file.bytes,
6658 },
6659 )
6660 .is_some()
6661 {
6662 first_error.get_or_insert_with(|| {
6663 invalid_feed("v2 bulk streams repeated a path")
6664 });
6665 }
6666 }
6667 Err(error) => {
6668 first_error.get_or_insert(error);
6669 }
6670 }
6671 }
6672 }
6673 Err(error) => {
6674 first_error.get_or_insert(error);
6675 }
6676 }
6677 }
6678 });
6679 match first_error {
6680 Some(error) => Err(error),
6681 None => Ok(()),
6682 }
6683}
6684
6685#[cfg(any(unix, windows))]
6686fn stage_v2_blobs(
6687 cfg: &HubConfig,
6688 brain: &str,
6689 pointer: &V2PointerBody,
6690 pending: Vec<(&String, &V2BaselineFile)>,
6691) -> LinkResult<Vec<V2StagedFile>> {
6692 let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6693 let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6694 let mut direct = Vec::new();
6695 let mut window = Vec::new();
6696 let mut missing_windows = Vec::new();
6697 let mut window_bytes = 0_u64;
6698 for &(path, file) in &pending {
6699 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6700 queue_v2_bulk_window(
6701 &cache_dir,
6702 &mut window,
6703 &mut window_bytes,
6704 &mut staged,
6705 &mut missing_windows,
6706 )?;
6707 direct.push((path, file));
6708 continue;
6709 }
6710 if window.len() == V2_BULK_STREAM_FILES
6711 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6712 {
6713 queue_v2_bulk_window(
6714 &cache_dir,
6715 &mut window,
6716 &mut window_bytes,
6717 &mut staged,
6718 &mut missing_windows,
6719 )?;
6720 }
6721 window.push((path, file));
6722 window_bytes += file.bytes;
6723 }
6724 queue_v2_bulk_window(
6725 &cache_dir,
6726 &mut window,
6727 &mut window_bytes,
6728 &mut staged,
6729 &mut missing_windows,
6730 )?;
6731 stage_v2_bulk_windows(
6732 cfg,
6733 brain,
6734 pointer,
6735 &cache_dir,
6736 missing_windows,
6737 &mut staged,
6738 )?;
6739 for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6740 let source =
6741 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6742 staged.insert(
6743 item.path.clone(),
6744 V2StagedFile {
6745 path: item.path,
6746 source,
6747 sha256: item.sha256,
6748 bytes: item.bytes,
6749 },
6750 );
6751 }
6752 pending
6753 .into_iter()
6754 .map(|(path, _)| {
6755 staged
6756 .remove(path)
6757 .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6758 })
6759 .collect()
6760}
6761
6762#[cfg(not(any(unix, windows)))]
6763fn stage_v2_blobs(
6764 _cfg: &HubConfig,
6765 _brain: &str,
6766 _pointer: &V2PointerBody,
6767 _pending: Vec<(&String, &V2BaselineFile)>,
6768) -> LinkResult<Vec<V2StagedFile>> {
6769 Err(LinkError::UnsupportedPlatform {
6770 operation: "resumable v2 download staging",
6771 })
6772}
6773
6774const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6775const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6776const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6777
6778#[derive(Debug, Clone, Deserialize, Serialize)]
6779struct V2ConflictCoordinate {
6780 sha256: Option<String>,
6781 bytes: Option<u64>,
6782 file: Option<String>,
6783}
6784
6785#[derive(Debug, Clone, Deserialize, Serialize)]
6786struct V2ConflictFile {
6787 path: String,
6788 base: V2ConflictCoordinate,
6789 local: V2ConflictCoordinate,
6790 remote: V2ConflictCoordinate,
6791}
6792
6793#[derive(Debug, Clone, Deserialize, Serialize)]
6794struct V2ConflictPlan {
6795 v: u8,
6796 class: String,
6797 bundle: String,
6798 brain: String,
6799 origin: String,
6800 created_unix: u64,
6801 expires_unix: u64,
6802 base_seq: Option<u64>,
6803 base_commit: Option<String>,
6804 remote_seq: u64,
6805 remote_commit: Option<String>,
6806 remote_content_root: Option<String>,
6807 view_kind: String,
6808 view_revision: String,
6809 files: Vec<V2ConflictFile>,
6810}
6811
6812fn v2_take_remote_selection(
6813 files: &[V2ConflictFile],
6814 current: &std::collections::BTreeMap<String, V2BaselineFile>,
6815) -> LinkResult<(
6816 std::collections::BTreeMap<String, V2BaselineFile>,
6817 Vec<String>,
6818)> {
6819 let mut selected = std::collections::BTreeMap::new();
6820 let mut deleted = Vec::new();
6821 for file in files {
6822 match (&file.remote.sha256, file.remote.bytes) {
6823 (Some(sha256), Some(bytes)) => {
6824 let proven = current.get(&file.path).ok_or_else(|| {
6825 invalid_feed("conflict remote coordinate disappeared from the exact head")
6826 })?;
6827 if proven.sha256 != *sha256 || proven.bytes != bytes {
6828 return Err(invalid_feed(
6829 "conflict remote coordinate differs from the exact head",
6830 ));
6831 }
6832 if selected.insert(file.path.clone(), proven.clone()).is_some() {
6833 return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6834 }
6835 }
6836 (None, None) => {
6837 if current.contains_key(&file.path) {
6838 return Err(invalid_feed(
6839 "conflict remote deletion differs from the exact head",
6840 ));
6841 }
6842 deleted.push(file.path.clone());
6843 }
6844 _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6845 }
6846 }
6847 Ok((selected, deleted))
6848}
6849
6850fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6851 PathBuf::from(".dbmd")
6852 .join("conflicts")
6853 .join(bundle)
6854 .join(suffix)
6855}
6856
6857fn read_historical_conflict_blob(
6858 cfg: &HubConfig,
6859 brain: &str,
6860 baseline: &V2SyncBaseline,
6861 path: &str,
6862 file: &V2BaselineFile,
6863) -> LinkResult<Option<Vec<u8>>> {
6864 let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6865 return Ok(None);
6866 };
6867 if seq == 0 {
6868 return Ok(None);
6869 }
6870 let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6871 let endpoint = format!(
6872 "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6873 file.sha256
6874 );
6875 let history_http = hub_agent_with_timeout(cfg, std::time::Duration::from_secs(15))?;
6876 let raw = match request_raw_with_agent(
6877 cfg,
6878 &history_http,
6879 "GET",
6880 &endpoint,
6881 None,
6882 RawRequestOptions {
6883 auth: Auth::Required,
6884 max_response_bytes: file.bytes,
6885 request_id: None,
6886 retry_transport: false,
6887 },
6888 ) {
6889 Ok(raw) => raw,
6890 Err(LinkError::Transport { .. }) => return Ok(None),
6895 Err(error) => return Err(error),
6896 };
6897 if raw.status == 404 || raw.status == 403 {
6898 return Ok(None);
6899 }
6900 let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6901 if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6902 return Err(invalid_feed(
6903 "v2 conflict base failed integrity verification",
6904 ));
6905 }
6906 Ok(Some(bytes))
6907}
6908
6909fn create_v2_conflict_bundle(
6912 cfg: &HubConfig,
6913 store: &Store,
6914 head: &V2VerifiedHead,
6915 baseline: Option<&V2SyncBaseline>,
6916 local: &std::collections::BTreeMap<String, (String, u64)>,
6917 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6918 paths: &[String],
6919) -> LinkResult<(String, Vec<String>)> {
6920 let conflicts_root = Path::new(".dbmd/conflicts");
6921 store.create_dir_all(conflicts_root)?;
6922 let completed = store
6923 .directory_names(conflicts_root)?
6924 .into_iter()
6925 .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6926 .count();
6927 if completed >= V2_CONFLICT_BUNDLE_MAX {
6928 return Err(LinkError::InvalidPack {
6929 message: format!(
6930 "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6931 ),
6932 });
6933 }
6934
6935 let mut selected_paths = Vec::new();
6939 let mut selected_remote_bytes = 0_u64;
6940 for path in paths {
6941 let bytes = remote.get(path).map_or(0, |file| file.bytes);
6942 if !selected_paths.is_empty()
6943 && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6944 {
6945 break;
6946 }
6947 selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6948 selected_paths.push(path.clone());
6949 if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6950 break;
6951 }
6952 }
6953 if selected_paths.is_empty() {
6954 return Err(invalid_feed("content conflict set is empty"));
6955 }
6956 let bundle = crate::ulid::mint();
6957 let bundle_root = v2_conflict_relative(&bundle, "");
6958 store.create_dir_all(&bundle_root.join("files"))?;
6959 let pointer = head.pointer.as_ref();
6960 let remote_bytes = match pointer {
6961 Some(pointer) => download_v2_blobs(
6962 cfg,
6963 &head.brain_id,
6964 pointer,
6965 selected_paths
6966 .iter()
6967 .filter_map(|path| {
6968 remote
6969 .get(path)
6970 .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6971 .map(|file| (path, file))
6972 })
6973 .collect(),
6974 )?
6975 .into_iter()
6976 .collect::<std::collections::BTreeMap<_, _>>(),
6977 None => std::collections::BTreeMap::new(),
6978 };
6979
6980 let mut files = Vec::with_capacity(selected_paths.len());
6981 let mut historical_body_available = true;
6982 for (index, path) in selected_paths.iter().enumerate() {
6983 let base_file = baseline.and_then(|state| state.files.get(path));
6984 let base_bytes = match (historical_body_available, baseline, base_file) {
6985 (true, Some(state), Some(file)) => {
6986 let bytes = read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?;
6987 if bytes.is_none() {
6988 historical_body_available = false;
6993 }
6994 bytes
6995 }
6996 _ => None,
6997 };
6998 let local_file = local.get(path);
6999 let remote_file = remote.get(path);
7000 let remote_content = remote_bytes.get(path);
7001 let prefix = format!("files/{index:04}");
7002 let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
7003 let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
7004 let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
7005 if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
7006 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
7007 }
7008 if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
7009 let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
7010 if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
7011 return Err(LinkError::InvalidPack {
7012 message: format!("local conflict path `{path}` changed while bundling"),
7013 });
7014 }
7015 store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
7016 }
7017 if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
7018 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
7019 }
7020 files.push(V2ConflictFile {
7021 path: path.clone(),
7022 base: V2ConflictCoordinate {
7023 sha256: base_file.map(|file| file.sha256.clone()),
7024 bytes: base_file.map(|file| file.bytes),
7025 file: base_name,
7026 },
7027 local: V2ConflictCoordinate {
7028 sha256: local_file.map(|(sha256, _)| sha256.clone()),
7029 bytes: local_file.map(|(_, bytes)| *bytes),
7030 file: local_name,
7031 },
7032 remote: V2ConflictCoordinate {
7033 sha256: remote_file.map(|file| file.sha256.clone()),
7034 bytes: remote_file.map(|file| file.bytes),
7035 file: remote_name,
7036 },
7037 });
7038 }
7039 let now = SystemTime::now()
7040 .duration_since(UNIX_EPOCH)
7041 .unwrap_or_default()
7042 .as_secs();
7043 let plan = V2ConflictPlan {
7044 v: 2,
7045 class: "content_resolution_required".to_string(),
7046 bundle: bundle.clone(),
7047 brain: head.brain_id.clone(),
7048 origin: normalized_origin(&cfg.hub)?,
7049 created_unix: now,
7050 expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
7051 base_seq: baseline.and_then(|state| state.head_seq),
7052 base_commit: baseline.and_then(|state| state.commit_hash.clone()),
7053 remote_seq: pointer.map_or(0, |value| value.seq),
7054 remote_commit: pointer.map(|value| value.commit_hash.clone()),
7055 remote_content_root: pointer.and_then(|value| value.content_root.clone()),
7056 view_kind: head.view_kind.clone(),
7057 view_revision: head.view_revision.clone(),
7058 files,
7059 };
7060 let mut bytes = serde_json::to_vec_pretty(&plan)
7061 .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
7062 bytes.push(b'\n');
7063 store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
7064 Ok((bundle, selected_paths))
7065}
7066
7067fn v2_sync_pull_with_resolution(
7068 cfg: &HubConfig,
7069 requested_brain: &str,
7070 expected_head: V2VerifiedHead,
7071 out: Option<&Path>,
7072 take_remote: Option<&std::collections::BTreeSet<String>>,
7073) -> LinkResult<V2PulledSnapshot> {
7074 let dest = out
7075 .map(Path::to_path_buf)
7076 .unwrap_or_else(|| PathBuf::from(requested_brain));
7077 let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
7078 recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
7079 let head = v2_verified_head(cfg, requested_brain)?
7080 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7081 if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
7082 return Err(LinkError::RemoteAdvancedDuringSync);
7083 }
7084 let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
7085 ensure_v2_view_compatible(&head, baseline.as_ref())?;
7086 let (remote, remote_assets) = match baseline
7087 .as_ref()
7088 .filter(|state| v2_baseline_matches_head(&head, state))
7089 {
7090 Some(state) => (state.files.clone(), state.assets.clone()),
7091 None => (
7092 files_for_v2_view(
7093 &head,
7094 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7095 ),
7096 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7097 ),
7098 };
7099 verify_v2_markdown_asset_content_bindings(&remote, &remote_assets)?;
7100 let local_store = Store::open_strict(&dest).ok();
7101 ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
7106 let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
7107 if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
7108 return Err(LinkError::ScopedViewChanged);
7109 }
7110 if let Some(view) = local_view.as_mut() {
7111 remove_scoped_projection(&head, baseline.as_ref(), view)?;
7112 }
7113 let empty_local = std::collections::BTreeMap::new();
7114 let local = local_view
7115 .as_ref()
7116 .map_or(&empty_local, |view| &view.riding);
7117 let kept_home = |path: &str| {
7118 local_view
7119 .as_ref()
7120 .is_some_and(|view| view.policy.keeps_home(path))
7121 };
7122 let empty_base = std::collections::BTreeMap::new();
7123 let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
7124 let empty_base_assets = std::collections::BTreeMap::new();
7125 let base_assets = baseline
7126 .as_ref()
7127 .map_or(&empty_base_assets, |state| &state.assets);
7128 let mut local_assets = local_store
7129 .as_ref()
7130 .map(v2_local_asset_records)
7131 .transpose()?
7132 .unwrap_or_default();
7133 let mut content_merge = merge_v2_pulled_records(
7134 base,
7135 &remote,
7136 local,
7137 |file, _| (file.sha256.clone(), file.bytes),
7138 |file, _| (file.sha256.clone(), file.bytes),
7139 kept_home,
7140 );
7141 if let Some(selected) = take_remote {
7142 for path in selected {
7143 if let Some(position) = content_merge
7144 .conflicts
7145 .iter()
7146 .position(|conflict| conflict == path)
7147 {
7148 content_merge.conflicts.remove(position);
7149 content_merge.accept_remote.insert(path.clone());
7150 match remote.get(path) {
7151 Some(file) => {
7152 content_merge
7153 .records
7154 .insert(path.clone(), (file.sha256.clone(), file.bytes));
7155 }
7156 None => {
7157 content_merge.records.remove(path);
7158 }
7159 }
7160 } else if !content_merge.accept_remote.contains(path) {
7161 return Err(LinkError::InvalidPack {
7162 message: format!(
7163 "take-remote path `{path}` is no longer at its conflict coordinate"
7164 ),
7165 });
7166 }
7167 }
7168 }
7169 if !content_merge.conflicts.is_empty() {
7170 let mut conflicts = content_merge.conflicts.clone();
7171 conflicts.truncate(100);
7172 if let Some(store) = local_store.as_ref() {
7173 let (bundle, paths) = create_v2_conflict_bundle(
7174 cfg,
7175 store,
7176 &head,
7177 baseline.as_ref(),
7178 local,
7179 &remote,
7180 &conflicts,
7181 )?;
7182 return Err(LinkError::ConflictBundle { bundle, paths });
7183 }
7184 return Err(LinkError::Conflict { paths: conflicts });
7185 }
7186 let asset_merge = merge_v2_pulled_records(
7187 base_assets,
7188 &remote_assets,
7189 &local_assets,
7190 v2_asset_record,
7191 v2_asset_record,
7192 |_| false,
7193 );
7194 if !asset_merge.conflicts.is_empty() {
7195 let mut conflicts = asset_merge.conflicts.clone();
7196 conflicts.truncate(100);
7197 return Err(LinkError::Conflict { paths: conflicts });
7198 }
7199 let pointer = head.pointer.as_ref();
7200 let cache_transaction = pointer.map_or_else(
7201 || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
7202 |value| value.commit_hash.clone(),
7203 );
7204 let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
7205 let mut changed = match pointer {
7206 Some(pointer) => stage_v2_blobs(
7207 cfg,
7208 &head.brain_id,
7209 pointer,
7210 remote
7211 .iter()
7212 .filter(|(path, file)| {
7213 content_merge.accept_remote.contains(*path)
7214 && local.get(*path).map(|value| value.0.as_str())
7215 != Some(file.sha256.as_str())
7216 })
7217 .collect(),
7218 )?,
7219 None => Vec::new(),
7220 };
7221 let mut deleted = content_merge
7222 .accept_remote
7223 .iter()
7224 .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
7225 .cloned()
7226 .collect::<Vec<_>>();
7227 if local_assets != asset_merge.records {
7228 if asset_merge.records.is_empty() {
7229 deleted.push("assets.jsonl".to_string());
7230 } else {
7231 let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
7232 let sha256 = content_sha256(&bytes);
7233 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
7234 changed.push(V2StagedFile {
7235 path: "assets.jsonl".to_string(),
7236 source,
7237 sha256,
7238 bytes: bytes.len() as u64,
7239 });
7240 }
7241 }
7242 if let Some(pointer) = pointer {
7243 let mut pending_assets = Vec::new();
7244 for (path, asset) in &remote_assets {
7245 if asset.disposition != "hosted"
7246 || kept_home(path)
7247 || !asset_merge.accept_remote.contains(path)
7248 {
7249 continue;
7250 }
7251 if is_markdown_asset_path(path) {
7252 continue;
7255 }
7256 let already_current = 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), asset.bytes)
7260 .ok()
7261 .is_some_and(|bytes| {
7262 bytes.len() as u64 == asset.bytes
7263 && content_sha256(&bytes) == asset.blob_sha256
7264 })
7265 });
7266 if !already_current {
7267 pending_assets.push((path, asset));
7268 }
7269 }
7270 let mut window = Vec::new();
7271 let mut window_bytes = 0_u64;
7272 let flush = |window: &mut Vec<(&String, &V2BaselineAsset)>,
7273 window_bytes: &mut u64,
7274 changed: &mut Vec<V2StagedFile>|
7275 -> LinkResult<()> {
7276 changed.extend(stage_v2_asset_download_window(
7277 cfg,
7278 &head.brain_id,
7279 pointer,
7280 &cache_dir,
7281 window,
7282 )?);
7283 window.clear();
7284 *window_bytes = 0;
7285 Ok(())
7286 };
7287 for item @ (_, asset) in pending_assets {
7288 if !window.is_empty()
7289 && (window.len() == V2_DOWNLOAD_CAPABILITY_FILES
7290 || window_bytes.saturating_add(asset.bytes) > V2_DOWNLOAD_CAPABILITY_BYTES)
7291 {
7292 flush(&mut window, &mut window_bytes, &mut changed)?;
7293 }
7294 window.push(item);
7295 window_bytes = window_bytes.saturating_add(asset.bytes);
7296 if window_bytes >= V2_DOWNLOAD_CAPABILITY_BYTES {
7297 flush(&mut window, &mut window_bytes, &mut changed)?;
7298 }
7299 }
7300 flush(&mut window, &mut window_bytes, &mut changed)?;
7301 }
7302 for (path, prior) in base_assets {
7303 if remote_assets.contains_key(path)
7304 || kept_home(path)
7305 || !asset_merge.accept_remote.contains(path)
7306 {
7307 continue;
7308 }
7309 let unchanged = local_store.as_ref().is_some_and(|store| {
7310 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7311 && store
7312 .read_bounded(Path::new(path), prior.bytes)
7313 .ok()
7314 .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
7315 });
7316 if unchanged {
7317 deleted.push(path.clone());
7318 }
7319 }
7320 let extra_local = content_merge
7321 .records
7322 .keys()
7323 .filter(|path| !remote.contains_key(*path))
7324 .cloned()
7325 .collect::<Vec<_>>();
7326 if head.view_kind == "scoped" {
7327 for (path, bytes) in [
7328 ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
7329 (
7330 ".dbmd/view.json".to_string(),
7331 scoped_view_metadata(&head, remote.len())?,
7332 ),
7333 ] {
7334 let sha256 = content_sha256(&bytes);
7335 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
7336 changed.push(V2StagedFile {
7337 path,
7338 source,
7339 sha256,
7340 bytes: bytes.len() as u64,
7341 });
7342 }
7343 }
7344 let install_changed = !changed.is_empty() || !deleted.is_empty();
7345 install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
7346 let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
7347 let installed_store =
7348 Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
7349 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
7350 })?;
7351 let installed_local = if install_changed {
7352 let hint = baseline.as_ref().and_then(|state| {
7353 state
7354 .local_policy_digest
7355 .as_deref()
7356 .map(|digest| (digest, &state.scan_cache))
7357 });
7358 let mut scanned = v2_local_files_cached(&installed_store, hint)?;
7359 remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
7360 scanned
7361 } else {
7362 local_view
7363 .take()
7364 .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
7365 };
7366 if installed_local.riding != content_merge.records {
7367 return Err(LinkError::InvalidPack {
7368 message: "local content changed while installing the v2 pull".to_string(),
7369 });
7370 }
7371 let installed_assets = if install_changed {
7372 v2_local_asset_records(&installed_store)?
7373 } else {
7374 std::mem::take(&mut local_assets)
7375 };
7376 if installed_assets != asset_merge.records {
7377 return Err(LinkError::InvalidPack {
7378 message: "local assets changed while installing the v2 pull".to_string(),
7379 });
7380 }
7381 let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
7382 installed_local.policy.keeps_home(path)
7383 })
7384 || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
7385 let final_head = v2_verified_head(cfg, requested_brain)?
7386 .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
7387 if !same_v2_head(&head, &final_head) {
7388 return Err(LinkError::RemoteAdvancedDuringSync);
7389 }
7390 accept_v2_head(cfg, &final_head)?;
7391 save_v2_baseline(
7392 cfg,
7393 &head.brain_id,
7394 &dest,
7395 &v2_baseline_from_head(
7396 cfg,
7397 &head,
7398 remote.clone(),
7399 remote_assets.clone(),
7400 Some(&installed_local),
7401 baseline
7402 .as_ref()
7403 .and_then(|current| current.checkout_id.as_deref()),
7404 )?,
7405 )?;
7406 complete_v2_pull(&dest)?;
7407 Ok((local_dirty, installed_local, installed_assets))
7408 })();
7409 let (local_dirty, installed_local, installed_assets) = match finalized {
7410 Ok(value) => value,
7411 Err(error) => {
7412 if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
7413 return Err(LinkError::InvalidPack {
7414 message: format!("{error}; durable pull recovery also failed: {recovery}"),
7415 });
7416 }
7417 return Err(error);
7418 }
7419 };
7420 cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
7421 let report = PullReport {
7422 brain: head.brain_id.clone(),
7423 slug: requested_brain.to_string(),
7424 head_seq: pointer.map_or(0, |value| value.seq),
7425 files: remote.len() + remote_assets.len(),
7426 dest: dest.to_string_lossy().into_owned(),
7427 extra_local,
7428 sync_status: if local_dirty {
7429 "local_dirty_after_install".to_string()
7430 } else {
7431 "synced".to_string()
7432 },
7433 };
7434 Ok(V2PulledSnapshot {
7435 report,
7436 head,
7437 files: remote,
7438 assets: remote_assets,
7439 local: installed_local,
7440 local_assets: installed_assets,
7441 })
7442}
7443
7444fn v2_sync_pull(
7445 cfg: &HubConfig,
7446 requested_brain: &str,
7447 head: V2VerifiedHead,
7448 out: Option<&Path>,
7449) -> LinkResult<PullReport> {
7450 Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
7451}
7452
7453fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
7454 match remote {
7455 Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
7456 None => json!({ "kind": "absent" }),
7457 }
7458}
7459
7460fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
7461 match remote {
7462 Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
7463 None => json!({ "kind": "absent" }),
7464 }
7465}
7466
7467fn v2_content_withdrawal_operation(
7468 store: &Store,
7469 local_view: &V2LocalView,
7470 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7471 path: &str,
7472 reason: &str,
7473) -> LinkResult<Value> {
7474 if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
7475 || path == "DB.md"
7476 {
7477 return Err(LinkError::InvalidPack {
7478 message: format!("content withdrawal path `{path}` is not a record or source"),
7479 });
7480 }
7481 if !local_view.policy.keeps_home(path)
7482 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7483 {
7484 return Err(LinkError::InvalidPack {
7485 message: format!(
7486 "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7487 ),
7488 });
7489 }
7490 let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
7491 message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
7492 })?;
7493 verify_v2_upload_source(store, path, ¤t.sha256, current.bytes)?;
7494 Ok(json!({
7495 "op": "withdraw_from_hosting",
7496 "path": path,
7497 "expected": { "kind": "blob", "hash": current.sha256 },
7498 "reason": reason,
7499 }))
7500}
7501
7502fn v2_asset_withdrawal_operation(
7503 store: &Store,
7504 local_view: &V2LocalView,
7505 path: &str,
7506 local: &crate::AssetRecord,
7507 current: &V2BaselineAsset,
7508 reason: &str,
7509) -> LinkResult<Value> {
7510 if !local_view.policy.keeps_home(path)
7511 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7512 {
7513 return Err(LinkError::InvalidPack {
7514 message: format!(
7515 "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7516 ),
7517 });
7518 }
7519 verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
7520 if current.disposition != "hosted"
7521 || current.blob_sha256 != local.sha256
7522 || current.bytes != local.bytes
7523 || current.media_type != local.media_type
7524 {
7525 return Err(LinkError::InvalidPack {
7526 message: format!(
7527 "asset withdrawal path `{path}` must preserve the currently hosted blob identity, byte count, and media type"
7528 ),
7529 });
7530 }
7531 Ok(json!({
7532 "op": "asset_withdraw",
7533 "path": path,
7534 "expected": v2_asset_expected(Some(current)),
7535 "asset": v2_asset_value(local, "withheld"),
7536 "reason": reason,
7537 }))
7538}
7539
7540fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
7547 let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
7548 let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
7549 for (index, operation) in operations.iter().enumerate() {
7550 match operation.get("op").and_then(Value::as_str) {
7551 Some("delete") => {
7552 let Some(path) = operation.get("path").and_then(Value::as_str) else {
7553 continue;
7554 };
7555 let Some(hash) = operation
7556 .get("expected")
7557 .and_then(|value| value.get("hash"))
7558 .and_then(Value::as_str)
7559 else {
7560 continue;
7561 };
7562 if path.starts_with("sources/") {
7563 deletes
7564 .entry(hash.to_string())
7565 .or_default()
7566 .push((index, path.to_string()));
7567 }
7568 }
7569 Some("put" | "put_asset_content") => {
7570 let Some(path) = operation.get("path").and_then(Value::as_str) else {
7571 continue;
7572 };
7573 let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
7574 continue;
7575 };
7576 let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
7577 continue;
7578 };
7579 let destination_absent = operation
7580 .get("expected")
7581 .and_then(|value| value.get("kind"))
7582 .and_then(Value::as_str)
7583 == Some("absent");
7584 if path.starts_with("sources/") && destination_absent {
7585 puts.entry(hash.to_string()).or_default().push((
7586 index,
7587 path.to_string(),
7588 bytes,
7589 ));
7590 }
7591 }
7592 _ => {}
7593 }
7594 }
7595 let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
7596 let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
7597 for (hash, source) in deletes {
7598 let Some(destination) = puts.get(&hash) else {
7599 continue;
7600 };
7601 if source.len() != 1 || destination.len() != 1 {
7602 continue;
7603 }
7604 let (delete_index, from) = &source[0];
7605 let (put_index, to, bytes) = &destination[0];
7606 if from == to {
7607 continue;
7608 }
7609 rename_at.insert(
7610 *delete_index,
7611 json!({
7612 "op": "rename",
7613 "from": from,
7614 "to": to,
7615 "expected_from": { "kind": "blob", "hash": hash },
7616 "expected_to": { "kind": "absent" },
7617 "blob": hash,
7618 "bytes": bytes,
7619 }),
7620 );
7621 consumed_puts.insert(*put_index);
7622 }
7623 operations
7624 .into_iter()
7625 .enumerate()
7626 .filter_map(|(index, operation)| {
7627 if let Some(rename) = rename_at.remove(&index) {
7628 Some(rename)
7629 } else if consumed_puts.contains(&index) {
7630 None
7631 } else {
7632 Some(operation)
7633 }
7634 })
7635 .collect()
7636}
7637
7638fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
7639 json!({
7640 "blob_sha256": record.sha256,
7641 "bytes": record.bytes,
7642 "media_type": record.media_type,
7643 "wrappers": record.wrappers,
7644 "required": record.required,
7645 "disposition": disposition,
7646 })
7647}
7648
7649fn apply_generated_v2_operations(
7653 operations: &[Value],
7654 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
7655 candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
7656 candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
7657) -> LinkResult<bool> {
7658 let mut asset_changed = false;
7659 for operation in operations {
7660 match operation.get("op").and_then(Value::as_str) {
7661 Some("put" | "put_asset_content") => {
7662 let path = operation
7663 .get("path")
7664 .and_then(Value::as_str)
7665 .ok_or_else(|| invalid_feed("v2 put has no path"))?;
7666 let sha256 = operation
7667 .get("blob")
7668 .and_then(Value::as_str)
7669 .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
7670 let bytes = operation
7671 .get("bytes")
7672 .and_then(Value::as_u64)
7673 .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
7674 candidate.insert(
7675 path.to_string(),
7676 V2BaselineFile {
7677 sha256: sha256.to_string(),
7678 bytes,
7679 proof: None,
7680 },
7681 );
7682 }
7683 Some("rename") => {
7684 let from = operation
7685 .get("from")
7686 .and_then(Value::as_str)
7687 .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
7688 let to = operation
7689 .get("to")
7690 .and_then(Value::as_str)
7691 .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
7692 let sha256 = operation
7693 .get("blob")
7694 .and_then(Value::as_str)
7695 .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
7696 let bytes = operation
7697 .get("bytes")
7698 .and_then(Value::as_u64)
7699 .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
7700 let expected_from = operation
7701 .get("expected_from")
7702 .and_then(|expected| expected.get("hash"))
7703 .and_then(Value::as_str);
7704 let expected_to_absent = operation
7705 .get("expected_to")
7706 .and_then(|expected| expected.get("kind"))
7707 .and_then(Value::as_str)
7708 == Some("absent");
7709 if from == to
7710 || !from.starts_with("sources/")
7711 || !to.starts_with("sources/")
7712 || expected_from != Some(sha256)
7713 || !expected_to_absent
7714 || candidate.contains_key(to)
7715 {
7716 return Err(invalid_feed("generated v2 source rename is malformed"));
7717 }
7718 let source = candidate
7719 .remove(from)
7720 .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
7721 if source.sha256 != sha256 || source.bytes != bytes {
7722 return Err(invalid_feed(
7723 "v2 rename source differs from its exact-byte claim",
7724 ));
7725 }
7726 candidate.insert(
7727 to.to_string(),
7728 V2BaselineFile {
7729 sha256: sha256.to_string(),
7730 bytes,
7731 proof: None,
7732 },
7733 );
7734 }
7735 Some("delete" | "withdraw_from_hosting") => {
7736 let path = operation
7737 .get("path")
7738 .and_then(Value::as_str)
7739 .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7740 candidate.remove(path);
7741 }
7742 Some("asset_delete") => {
7743 let path = operation
7744 .get("path")
7745 .and_then(Value::as_str)
7746 .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7747 candidate_assets.remove(path);
7748 asset_changed = true;
7749 }
7750 Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7751 let path = operation
7752 .get("path")
7753 .and_then(Value::as_str)
7754 .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7755 let record = local_assets
7756 .get(path)
7757 .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7758 let disposition =
7759 if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7760 "withheld"
7761 } else {
7762 operation
7763 .get("asset")
7764 .and_then(|asset| asset.get("disposition"))
7765 .and_then(Value::as_str)
7766 .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7767 };
7768 candidate_assets.insert(
7769 path.to_string(),
7770 V2BaselineAsset {
7771 blob_sha256: record.sha256.clone(),
7772 bytes: record.bytes,
7773 media_type: record.media_type.clone(),
7774 wrappers: record.wrappers.clone(),
7775 required: record.required,
7776 disposition: disposition.to_string(),
7777 leaf_hash: String::new(),
7780 },
7781 );
7782 asset_changed = true;
7783 }
7784 _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7785 }
7786 }
7787 Ok(asset_changed)
7788}
7789
7790fn v2_riding_matches_remote(
7791 local: &std::collections::BTreeMap<String, (String, u64)>,
7792 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7793 keeps_home: impl Fn(&str) -> bool,
7794) -> bool {
7795 remote.iter().all(|(path, file)| {
7796 keeps_home(path)
7797 || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7798 }) && local.iter().all(|(path, (hash, _))| {
7799 remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7800 })
7801}
7802
7803fn v2_initial_content_conflicts(
7804 local: &std::collections::BTreeMap<String, (String, u64)>,
7805 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7806 resolving: bool,
7807) -> Vec<String> {
7808 if resolving {
7809 return Vec::new();
7817 }
7818 remote
7819 .iter()
7820 .filter(|(path, file)| {
7821 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7822 })
7823 .map(|(path, _)| path.clone())
7824 .collect()
7825}
7826
7827fn v2_resolution_allows_path(
7828 resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7829 path: &str,
7830 remote_present: bool,
7831) -> bool {
7832 resolution.is_none_or(|allowed| allowed.contains_key(path) || !remote_present)
7833}
7834
7835fn v2_resolution_allows_asset(
7836 resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7837 base: Option<&V2BaselineAsset>,
7838 remote: Option<&V2BaselineAsset>,
7839 local: Option<&crate::AssetRecord>,
7840) -> bool {
7841 let Some(allowed) = resolution else {
7842 return false;
7843 };
7844 let wrappers = base
7845 .into_iter()
7846 .flat_map(|asset| asset.wrappers.iter())
7847 .chain(remote.into_iter().flat_map(|asset| asset.wrappers.iter()))
7848 .chain(local.into_iter().flat_map(|asset| asset.wrappers.iter()))
7849 .collect::<BTreeSet<_>>();
7850 !wrappers.is_empty()
7851 && wrappers
7852 .iter()
7853 .all(|wrapper| allowed.contains_key(*wrapper))
7854}
7855
7856#[derive(Debug, Clone)]
7857struct V2ResolutionOverride {
7858 expected_remote: Option<String>,
7859 selected_local: Option<String>,
7860}
7861
7862#[derive(Debug, Clone)]
7863struct V2UploadSource {
7864 path: String,
7865 bytes: u64,
7866}
7867
7868struct V2SyncPushOptions<'a> {
7869 resume_local_policy: bool,
7870 bulk_confirmation: Option<&'a V2BulkConfirmation>,
7871 resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7872 pulled: Option<V2PulledSnapshot>,
7873 withdrawal_paths: &'a [String],
7874 withdrawal_reason: Option<&'a str>,
7875 allow_contract_phase: bool,
7880}
7881
7882fn verify_v2_upload_source(
7883 store: &Store,
7884 path: &str,
7885 sha256: &str,
7886 expected_bytes: u64,
7887) -> LinkResult<()> {
7888 let file = store.open_regular(Path::new(path))?;
7889 if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7890 return Err(LinkError::InvalidPack {
7891 message: format!("local path `{path}` changed during sync planning"),
7892 });
7893 }
7894 Ok(())
7895}
7896
7897struct V2PendingUpload<'a> {
7900 url: String,
7901 headers: Value,
7902 sha256: String,
7903 source: &'a V2UploadSource,
7904}
7905
7906const V2_UPLOAD_CONCURRENCY: usize = 16;
7913
7914fn upload_v2_batch_concurrently(
7918 cfg: &HubConfig,
7919 store: &Store,
7920 pending: &[V2PendingUpload<'_>],
7921) -> LinkResult<()> {
7922 if pending.is_empty() {
7923 return Ok(());
7924 }
7925 let urls = pending
7926 .iter()
7927 .map(|task| task.url.as_str())
7928 .collect::<Vec<_>>();
7929 let shared = shared_staging_agent(cfg, &urls);
7930 if pending.len() == 1 {
7931 let task = &pending[0];
7932 put_presigned_source(
7933 cfg,
7934 &task.url,
7935 &task.headers,
7936 store,
7937 task.source,
7938 shared.as_ref(),
7939 )?;
7940 return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7941 }
7942 let next = std::sync::atomic::AtomicUsize::new(0);
7943 let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7944 let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7945 std::thread::scope(|scope| {
7946 for _ in 0..workers {
7947 scope.spawn(|| loop {
7948 if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7949 return;
7950 }
7951 let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7952 let Some(task) = pending.get(index) else {
7953 return;
7954 };
7955 let outcome = put_presigned_source(
7956 cfg,
7957 &task.url,
7958 &task.headers,
7959 store,
7960 task.source,
7961 shared.as_ref(),
7962 )
7963 .and_then(|()| {
7964 verify_v2_upload_source(
7965 store,
7966 &task.source.path,
7967 &task.sha256,
7968 task.source.bytes,
7969 )
7970 });
7971 if let Err(error) = outcome {
7972 if let Ok(mut guard) = failure.lock() {
7973 guard.get_or_insert(error);
7974 }
7975 return;
7976 }
7977 });
7978 }
7979 });
7980 match failure.into_inner() {
7981 Ok(Some(error)) => Err(error),
7982 Ok(None) => Ok(()),
7983 Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7984 }
7985}
7986
7987fn put_presigned_source(
7988 cfg: &HubConfig,
7989 raw: &str,
7990 headers: &Value,
7991 store: &Store,
7992 source: &V2UploadSource,
7993 shared: Option<&ureq::Agent>,
7994) -> LinkResult<()> {
7995 put_presigned_source_with_budget(
7996 cfg,
7997 raw,
7998 headers,
7999 store,
8000 source,
8001 shared,
8002 std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
8003 )
8004}
8005
8006fn put_presigned_source_with_budget(
8007 cfg: &HubConfig,
8008 raw: &str,
8009 headers: &Value,
8010 store: &Store,
8011 source: &V2UploadSource,
8012 shared: Option<&ureq::Agent>,
8013 total_budget: std::time::Duration,
8014) -> LinkResult<()> {
8015 let owned = match shared {
8018 Some(_) => {
8019 checked_presigned_url(cfg, raw)?;
8020 None
8021 }
8022 None => Some(presigned_agent(cfg, raw)?),
8023 };
8024 let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
8025 let deadline = std::time::Instant::now()
8026 .checked_add(total_budget)
8027 .ok_or_else(upload_deadline_error)?;
8028 let mut attempt = 0;
8029 let result = loop {
8030 let file = store.open_regular(Path::new(&source.path))?;
8031 if file.metadata()?.len() != source.bytes {
8032 return Err(LinkError::InvalidPack {
8033 message: format!("local path `{}` changed before upload", source.path),
8034 });
8035 }
8036 let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
8041 let mut has_content_length = false;
8042 if let Some(map) = headers.as_object() {
8043 for (name, value) in map {
8044 if let Some(value) = value.as_str() {
8045 has_content_length |= name.eq_ignore_ascii_case("content-length");
8046 req = req.set(name, value);
8047 }
8048 }
8049 }
8050 if !has_content_length {
8051 req = req.set("Content-Length", &source.bytes.to_string());
8052 }
8053 match req.send(file) {
8054 Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
8060 attempt += 1;
8061 }
8062 Err(ureq::Error::Status(status, _))
8068 if status != 412
8069 && is_retryable_upload_status(status)
8070 && wait_for_upload_retry(deadline, attempt) =>
8071 {
8072 attempt += 1;
8073 }
8074 result => break result,
8075 }
8076 };
8077 match result {
8078 Ok(response) if (200..300).contains(&response.status()) => {
8079 drain_presigned_response(response);
8080 Ok(())
8081 }
8082 Ok(response) => {
8083 let status = response.status();
8088 let detail = response
8089 .into_string()
8090 .ok()
8091 .map(|body| body.chars().take(400).collect::<String>())
8092 .filter(|body| !body.trim().is_empty());
8093 Err(LinkError::Http {
8094 what: "v2 changed-byte upload",
8095 status,
8096 message: match detail {
8097 Some(body) => format!(
8098 "object store rejected the upload of `{}`: {}",
8099 source.path,
8100 body.replace('\n', " ")
8101 ),
8102 None => format!("object store rejected the upload of `{}`", source.path),
8103 },
8104 code: None,
8105 details: None,
8106 })
8107 }
8108 Err(error) => match error {
8109 ureq::Error::Status(412, _) => Ok(()),
8110 ureq::Error::Status(_, response) => {
8111 let status = response.status();
8112 let detail = response
8113 .into_string()
8114 .ok()
8115 .map(|body| body.chars().take(400).collect::<String>())
8116 .filter(|body| !body.trim().is_empty());
8117 Err(LinkError::Http {
8118 what: "v2 changed-byte upload",
8119 status,
8120 message: match detail {
8121 Some(body) => format!(
8122 "object store rejected the upload of `{}`: {}",
8123 source.path,
8124 body.replace('\n', " ")
8125 ),
8126 None => {
8127 format!("object store rejected the upload of `{}`", source.path)
8128 }
8129 },
8130 code: None,
8131 details: None,
8132 })
8133 }
8134 ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
8135 },
8136 }
8137}
8138
8139fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
8143 if body.get("operations").is_some() {
8144 return body.clone();
8145 }
8146 let mut value = body.clone();
8147 if let Some(map) = value.as_object_mut() {
8148 map.remove("staged_change");
8149 map.insert("operations".to_string(), Value::Array(operations.to_vec()));
8150 }
8151 value
8152}
8153
8154fn reserve_upload_window(
8158 cfg: &HubConfig,
8159 path: &str,
8160 body: &Value,
8161 what: &'static str,
8162) -> LinkResult<Value> {
8163 let mut attempt = 0;
8164 loop {
8165 let pause = |attempt: usize| {
8166 std::thread::sleep(std::time::Duration::from_millis(
8167 RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
8168 ));
8169 };
8170 match request(cfg, "POST", path, Some(body), Auth::Required) {
8171 Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
8176 pause(attempt);
8177 attempt += 1;
8178 }
8179 Err(error) => return Err(error),
8180 Ok(response) => {
8181 if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
8182 pause(attempt);
8183 attempt += 1;
8184 continue;
8185 }
8186 return ensure_ok(response, what);
8187 }
8188 }
8189 }
8190}
8191
8192fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
8196 let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
8197 .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
8198 if bytes.len() > MAX_STAGED_CHANGE_BYTES {
8199 return Err(LinkError::PushTooLarge {
8200 detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
8201 });
8202 }
8203 Ok(bytes)
8204}
8205
8206fn stage_v2_change(
8216 cfg: &HubConfig,
8217 requested_brain: &str,
8218 operations: &[Value],
8219 blobs: Value,
8220) -> LinkResult<Value> {
8221 let bytes = v2_change_manifest(operations, blobs)?;
8222 let sha256 = content_sha256(&bytes);
8223 let reserved = reserve_upload_window(
8224 cfg,
8225 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8226 &json!({
8227 "blobs": [{
8228 "sha256": sha256,
8229 "bytes": bytes.len(),
8230 "kind": "staged_change",
8231 }],
8232 }),
8233 "stage the v2 change",
8234 )?;
8235 let items = reserved
8236 .get("uploads")
8237 .and_then(Value::as_array)
8238 .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
8239 let [item] = items.as_slice() else {
8240 return Err(invalid_feed(
8241 "v2 change staging response changed the requested set",
8242 ));
8243 };
8244 let reservation_id = item
8245 .get("reservation_id")
8246 .and_then(Value::as_str)
8247 .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
8248 if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
8249 || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
8250 || !crate::ulid::is_ulid(reservation_id)
8251 {
8252 return Err(invalid_feed("v2 change staging item is inconsistent"));
8253 }
8254 match item.get("status").and_then(Value::as_str) {
8255 Some("upload") => put_presigned(
8256 cfg,
8257 item.get("url")
8258 .and_then(Value::as_str)
8259 .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
8260 item.get("headers").unwrap_or(&Value::Null),
8261 &bytes,
8262 )?,
8263 Some("already_present") => {}
8264 _ => return Err(invalid_feed("v2 change staging has an unknown status")),
8265 }
8266 Ok(json!({
8267 "sha256": sha256,
8268 "bytes": bytes.len(),
8269 "reservation_id": reservation_id,
8270 }))
8271}
8272
8273fn stage_oversized_v2_change(
8277 cfg: &HubConfig,
8278 requested_brain: &str,
8279 operations: &[Value],
8280 body: &mut Value,
8281) -> LinkResult<()> {
8282 if body.to_string().len() <= MAX_PUSH_BYTES {
8283 return Ok(());
8284 }
8285 let staged = stage_v2_change(
8286 cfg,
8287 requested_brain,
8288 operations,
8289 body.get("blobs")
8290 .cloned()
8291 .unwrap_or(Value::Array(Vec::new())),
8292 )?;
8293 let map = body
8294 .as_object_mut()
8295 .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
8296 map.remove("operations");
8297 map.remove("blobs");
8298 map.insert("staged_change".to_string(), staged);
8299 Ok(())
8300}
8301
8302fn v2_sync_push(
8303 cfg: &HubConfig,
8304 requested_brain: &str,
8305 store: &Store,
8306 head: V2VerifiedHead,
8307 options: V2SyncPushOptions<'_>,
8308) -> LinkResult<Value> {
8309 let V2SyncPushOptions {
8310 resume_local_policy,
8311 bulk_confirmation,
8312 resolution,
8313 pulled,
8314 withdrawal_paths,
8315 withdrawal_reason,
8316 allow_contract_phase,
8317 } = options;
8318 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
8319 let head = v2_verified_head(cfg, requested_brain)?
8320 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
8321 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
8322 ensure_v2_view_compatible(&head, baseline.as_ref())?;
8323 let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
8324 match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
8325 Some(snapshot) => (
8326 snapshot.files,
8327 snapshot.assets,
8328 Some(snapshot.local),
8329 Some(snapshot.local_assets),
8330 ),
8331 None => match baseline
8332 .as_ref()
8333 .filter(|state| v2_baseline_matches_head(&head, state))
8334 {
8335 Some(state) => (state.files.clone(), state.assets.clone(), None, None),
8336 None => (
8337 files_for_v2_view(
8338 &head,
8339 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
8340 ),
8341 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
8342 None,
8343 None,
8344 ),
8345 },
8346 };
8347 if head.view_kind == "scoped" && baseline.is_none() {
8348 return Err(LinkError::ScopedViewChanged);
8349 }
8350 let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
8351 let local = &local_view.riding;
8352 let local_assets = match carried_local_assets {
8353 Some(assets) => assets,
8354 None => v2_local_asset_records(store)?,
8355 };
8356 if withdrawal_paths.len() > MAX_PUSH_FILES {
8357 return Err(LinkError::PushTooLarge {
8358 detail: "too many explicit withdrawal paths".to_string(),
8359 });
8360 }
8361 let withdrawal_reason = if withdrawal_paths.is_empty() {
8362 None
8363 } else {
8364 let reason = withdrawal_reason
8365 .map(str::trim)
8366 .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
8367 .ok_or_else(|| LinkError::InvalidPack {
8368 message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
8369 })?;
8370 Some(reason)
8371 };
8372 let mut withdrawals = withdrawal_paths
8373 .iter()
8374 .map(|path| {
8375 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
8376 path: error.to_string(),
8377 })
8378 })
8379 .collect::<LinkResult<Vec<_>>>()?;
8380 withdrawals.sort();
8381 withdrawals.dedup();
8382 if withdrawals.len() != withdrawal_paths.len() {
8383 return Err(LinkError::InvalidPack {
8384 message: "explicit withdrawal paths must be unique".to_string(),
8385 });
8386 }
8387 let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
8388 let mut consumed_withdrawals = BTreeSet::new();
8389 if let Some(previous) = baseline.as_ref() {
8390 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
8391 && !resume_local_policy
8392 {
8393 let mut newly_eligible = previous
8394 .local_eligibility
8395 .iter()
8396 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
8397 .map(|(path, _)| path.clone())
8398 .collect::<Vec<_>>();
8399 if !newly_eligible.is_empty() {
8400 newly_eligible.truncate(100);
8401 return Err(LinkError::LocalPolicyTransition {
8402 paths: newly_eligible,
8403 });
8404 }
8405 }
8406 }
8407 if baseline
8413 .as_ref()
8414 .is_some_and(|state| !v2_baseline_matches_head(&head, state))
8415 && resolution.is_none()
8416 && withdrawal_paths.is_empty()
8417 && v2_riding_matches_remote(local, &remote, |path| local_view.policy.keeps_home(path))
8418 && v2_asset_records_match_remote(&local_assets, &remote_assets)
8419 {
8420 let final_head = v2_verified_head(cfg, requested_brain)?
8421 .ok_or_else(|| invalid_feed("v2 head disappeared during baseline recovery"))?;
8422 if !same_v2_head(&head, &final_head) {
8423 return Err(LinkError::RemoteAdvancedDuringSync);
8424 }
8425 let mut final_local = v2_local_files_cached(
8426 store,
8427 Some((&local_view.policy.digest, &local_view.scan_cache)),
8428 )?;
8429 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8430 let final_assets = v2_local_asset_records(store)?;
8431 if final_local.riding != local_view.riding || final_assets != local_assets {
8432 return Err(LinkError::RemoteAdvancedDuringSync);
8433 }
8434 let checkout_pseudonym = v2_checkout_id(
8435 baseline
8436 .as_ref()
8437 .and_then(|current| current.checkout_id.as_deref()),
8438 )?;
8439 let next = v2_baseline_from_head(
8440 cfg,
8441 &head,
8442 remote,
8443 remote_assets,
8444 Some(&final_local),
8445 Some(&checkout_pseudonym),
8446 )?;
8447 let split_count = next.remote_copy_remains.len();
8448 accept_v2_head(cfg, &final_head)?;
8449 refresh_scoped_view_marker(store, &head, next.files.len())?;
8450 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8451 return Ok(json!({
8452 "v": 2,
8453 "outcome": "no_change",
8454 "sync_status": "synced",
8455 "baseline_recovered": true,
8456 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8457 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8458 "local_policy": {
8459 "remote_copy_remains": split_count,
8460 },
8461 }));
8462 }
8463 let base = match baseline.as_ref() {
8464 Some(state) => &state.files,
8465 None if remote.is_empty() => &remote,
8466 None => {
8467 let mut conflicts = v2_initial_content_conflicts(local, &remote, resolution.is_some());
8468 if !conflicts.is_empty() {
8469 conflicts.truncate(100);
8470 let (bundle, paths) =
8471 create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
8472 return Err(LinkError::ConflictBundle { bundle, paths });
8473 }
8474 &remote
8475 }
8476 };
8477 let all_paths = base
8478 .keys()
8479 .chain(remote.keys())
8480 .chain(local.keys())
8481 .cloned()
8482 .collect::<std::collections::BTreeSet<_>>();
8483 let mut conflicts = Vec::new();
8484 let mut operations = Vec::new();
8485 let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
8486 for path in all_paths {
8487 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
8488 let remote_file = remote.get(&path);
8489 let remote_hash = remote_file.map(|file| file.sha256.as_str());
8490 let local_file = local.get(&path);
8491 let local_hash = local_file.map(|file| file.0.as_str());
8492 if local_hash == remote_hash {
8497 continue;
8498 }
8499 if local_hash == base_hash {
8500 continue;
8501 }
8502 if !v2_resolution_allows_path(resolution, &path, remote_file.is_some()) {
8503 continue;
8504 }
8505 if local_view.policy.keeps_home(&path) {
8506 continue;
8509 }
8510 if remote_hash != base_hash && local_hash != remote_hash {
8511 let explicitly_resolved = resolution
8512 .and_then(|allowed| allowed.get(&path))
8513 .is_some_and(|selected| {
8514 selected.expected_remote.as_deref() == remote_hash
8515 && selected.selected_local.as_deref() == local_hash
8516 });
8517 if !explicitly_resolved {
8518 conflicts.push(path);
8519 continue;
8520 }
8521 }
8522 match local_file {
8523 Some((sha256, byte_count)) => {
8524 verify_v2_upload_source(store, &path, sha256, *byte_count)?;
8525 operations.push(json!({
8526 "op": v2_content_put_operation_kind(&path, &local_assets),
8527 "path": path,
8528 "expected": v2_expected(remote_file),
8529 "blob": sha256,
8530 "bytes": byte_count,
8531 }));
8532 upload_sources
8533 .entry(sha256.clone())
8534 .or_insert_with(|| V2UploadSource {
8535 path: path.clone(),
8536 bytes: *byte_count,
8537 });
8538 }
8539 None => {
8540 let Some(current) = remote_file else {
8541 continue;
8542 };
8543 operations.push(json!({
8544 "op": "delete",
8545 "path": path,
8546 "expected": { "kind": "blob", "hash": current.sha256 },
8547 }));
8548 }
8549 }
8550 }
8551 operations = infer_exact_source_promotions(operations);
8552 for path in &withdrawals {
8553 if !v2_withdrawal_includes_content(path, &local_assets) {
8554 continue;
8555 }
8556 operations.push(v2_content_withdrawal_operation(
8557 store,
8558 &local_view,
8559 &remote,
8560 path,
8561 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8562 )?);
8563 consumed_withdrawals.insert(path.clone());
8564 }
8565 if !conflicts.is_empty() {
8566 conflicts.truncate(100);
8567 let (bundle, paths) = create_v2_conflict_bundle(
8568 cfg,
8569 store,
8570 &head,
8571 baseline.as_ref(),
8572 local,
8573 &remote,
8574 &conflicts,
8575 )?;
8576 return Err(LinkError::ConflictBundle { bundle, paths });
8577 }
8578 let base_assets = match baseline.as_ref() {
8579 Some(state) => &state.assets,
8580 None if remote_assets.is_empty() => &remote_assets,
8581 None => {
8582 let mismatched = remote_assets
8583 .keys()
8584 .chain(local_assets.keys())
8585 .collect::<BTreeSet<_>>()
8586 .into_iter()
8587 .filter(|path| {
8588 remote_assets
8589 .get(*path)
8590 .map(|asset| v2_asset_record(asset, path))
8591 .as_ref()
8592 != local_assets.get(*path)
8593 })
8594 .collect::<Vec<_>>();
8595 let resolution_covers_all = !mismatched.is_empty()
8596 && mismatched.iter().all(|path| {
8597 v2_resolution_allows_asset(
8598 resolution,
8599 None,
8600 remote_assets.get(*path),
8601 local_assets.get(*path),
8602 )
8603 });
8604 if !mismatched.is_empty() && !resolution_covers_all {
8605 return Err(LinkError::Conflict {
8606 paths: vec!["assets.jsonl".to_string()],
8607 });
8608 }
8609 &remote_assets
8610 }
8611 };
8612 let asset_paths = base_assets
8613 .keys()
8614 .chain(remote_assets.keys())
8615 .chain(local_assets.keys())
8616 .cloned()
8617 .collect::<std::collections::BTreeSet<_>>();
8618 let mut asset_policy_transitions = Vec::new();
8619 let mut asset_withdrawal_transitions = Vec::new();
8620 for path in asset_paths {
8621 let base_record = base_assets
8622 .get(&path)
8623 .map(|asset| v2_asset_record(asset, &path));
8624 let remote = remote_assets.get(&path);
8625 let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
8626 let local_record = local_assets.get(&path);
8627 if withdrawal_set.contains(&path) {
8628 let record = local_record.ok_or_else(|| LinkError::InvalidPack {
8629 message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
8630 })?;
8631 let current = remote.ok_or_else(|| LinkError::InvalidPack {
8632 message: format!(
8633 "asset withdrawal path `{path}` has no readable hosted coordinate"
8634 ),
8635 })?;
8636 operations.push(v2_asset_withdrawal_operation(
8637 store,
8638 &local_view,
8639 &path,
8640 record,
8641 current,
8642 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8643 )?);
8644 consumed_withdrawals.insert(path.clone());
8645 continue;
8646 }
8647 let mut raw_present = false;
8648 let mut disposition = "withheld";
8649 let mut resumes_hosting = false;
8650 if let Some(record) = local_record {
8651 crate::linkmd_v2::normalize_path(&record.path)
8652 .map_err(|error| invalid_feed(error.to_string()))?;
8653 let kept_home = local_view.policy.keeps_home(&path);
8654 raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
8655 disposition = if kept_home || !raw_present {
8656 "withheld"
8657 } else {
8658 "hosted"
8659 };
8660 let inherits_withheld_absence = v2_asset_inherits_withheld_absence(
8661 base_assets.get(&path),
8662 base_record.as_ref(),
8663 local_record,
8664 raw_present,
8665 );
8666 if !raw_present && record.required && !kept_home && !inherits_withheld_absence {
8667 return Err(LinkError::InvalidPack {
8668 message: format!("required asset {path} is missing"),
8669 });
8670 }
8671 resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
8672 if remote.is_some_and(|asset| asset.disposition == "hosted")
8673 && disposition == "withheld"
8674 {
8675 asset_withdrawal_transitions.push(path.clone());
8676 continue;
8677 }
8678 }
8679 if local_record == base_record.as_ref() && !resumes_hosting {
8680 continue;
8681 }
8682 if remote_record != base_record
8683 && local_record != remote_record.as_ref()
8684 && !v2_resolution_allows_asset(resolution, base_assets.get(&path), remote, local_record)
8685 {
8686 conflicts.push(path);
8687 continue;
8688 }
8689 let Some(record) = local_record else {
8690 if let Some(remote) = remote {
8691 operations.push(json!({
8692 "op": "asset_delete",
8693 "path": path,
8694 "expected": v2_asset_expected(Some(remote)),
8695 }));
8696 }
8697 continue;
8698 };
8699 let raw = if raw_present {
8700 verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
8701 Some(())
8702 } else {
8703 None
8704 };
8705 let op = if resumes_hosting {
8706 if !resume_local_policy {
8707 asset_policy_transitions.push(path);
8708 continue;
8709 }
8710 "asset_resume"
8711 } else {
8712 "asset_put"
8713 };
8714 operations.push(json!({
8715 "op": op,
8716 "path": path,
8717 "expected": v2_asset_expected(remote),
8718 "asset": v2_asset_value(record, disposition),
8719 }));
8720 if disposition == "hosted" {
8721 raw.expect("hosted asset was checked present");
8722 upload_sources
8723 .entry(record.sha256.clone())
8724 .or_insert_with(|| V2UploadSource {
8725 path: path.clone(),
8726 bytes: record.bytes,
8727 });
8728 }
8729 }
8730 if consumed_withdrawals != withdrawal_set {
8731 let missing = withdrawal_set
8732 .difference(&consumed_withdrawals)
8733 .next()
8734 .expect("different withdrawal sets have one member");
8735 return Err(LinkError::InvalidPack {
8736 message: format!(
8737 "withdrawal path `{missing}` is not a readable content or asset coordinate"
8738 ),
8739 });
8740 }
8741 if !conflicts.is_empty() {
8742 conflicts.truncate(100);
8743 return Err(LinkError::Conflict { paths: conflicts });
8744 }
8745 if !asset_policy_transitions.is_empty() {
8746 asset_policy_transitions.truncate(100);
8747 return Err(LinkError::LocalPolicyTransition {
8748 paths: asset_policy_transitions,
8749 });
8750 }
8751 if !asset_withdrawal_transitions.is_empty() {
8752 asset_withdrawal_transitions.truncate(100);
8753 return Err(LinkError::AssetWithdrawalRequired {
8754 paths: asset_withdrawal_transitions,
8755 });
8756 }
8757 let contract_phase = operations.len() > 1
8758 && operations.iter().any(|operation| {
8759 operation.get("path").and_then(Value::as_str) == Some("DB.md")
8760 && !operation
8761 .get("op")
8762 .and_then(Value::as_str)
8763 .is_some_and(|kind| kind.starts_with("asset_"))
8764 });
8765 if contract_phase {
8766 if !allow_contract_phase {
8767 return Err(LinkError::RemoteAdvancedDuringSync);
8768 }
8769 operations.retain(|operation| {
8770 operation.get("path").and_then(Value::as_str) == Some("DB.md")
8771 && !operation
8772 .get("op")
8773 .and_then(Value::as_str)
8774 .is_some_and(|kind| kind.starts_with("asset_"))
8775 });
8776 let contract_blobs = operations
8777 .iter()
8778 .filter_map(|operation| operation.get("blob").and_then(Value::as_str))
8779 .collect::<BTreeSet<_>>();
8780 upload_sources.retain(|sha256, _| contract_blobs.contains(sha256.as_str()));
8781 }
8782 let touched_sources = operations
8783 .iter()
8784 .filter_map(
8785 |operation| match operation.get("op").and_then(Value::as_str) {
8786 Some("put" | "put_asset_content" | "restore") => {
8787 operation.get("path").and_then(Value::as_str)
8788 }
8789 Some("rename") => operation.get("to").and_then(Value::as_str),
8790 _ => None,
8791 },
8792 )
8793 .collect::<std::collections::BTreeSet<_>>();
8794 let withheld_links = local_view
8795 .withheld_links
8796 .iter()
8797 .filter(|link| touched_sources.contains(link.source.as_str()))
8798 .collect::<Vec<_>>();
8799 let checkout_pseudonym = v2_checkout_id(
8800 baseline
8801 .as_ref()
8802 .and_then(|current| current.checkout_id.as_deref()),
8803 )?;
8804 let checkout_id = if withheld_links.is_empty() {
8805 None
8806 } else {
8807 Some(checkout_pseudonym.clone())
8808 };
8809 if operations.is_empty() {
8810 let final_head = v2_verified_head(cfg, requested_brain)?
8811 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
8812 if !same_v2_head(&head, &final_head) {
8813 return Err(LinkError::RemoteAdvancedDuringSync);
8814 }
8815 let mut final_local = v2_local_files_cached(
8816 store,
8817 Some((&local_view.policy.digest, &local_view.scan_cache)),
8818 )?;
8819 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8820 let final_assets = v2_local_asset_records(store)?;
8821 let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
8822 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
8823 final_local.policy.keeps_home(path)
8824 }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
8825 let next = v2_baseline_from_head(
8826 cfg,
8827 &head,
8828 remote,
8829 remote_assets,
8830 Some(&final_local),
8831 Some(&checkout_pseudonym),
8832 )?;
8833 let split_count = next.remote_copy_remains.len();
8834 accept_v2_head(cfg, &final_head)?;
8835 if !remote_ahead {
8836 refresh_scoped_view_marker(store, &head, next.files.len())?;
8837 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8838 }
8839 return Ok(json!({
8840 "v": 2,
8841 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
8842 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
8843 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8844 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8845 "local_policy": {
8846 "remote_copy_remains": split_count,
8847 },
8848 }));
8849 }
8850 let includes_contract = operations
8851 .iter()
8852 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
8853 let rebase = if head.pointer.is_none() || includes_contract {
8854 "strict"
8855 } else {
8856 "disjoint"
8857 };
8858 let base_value = head.pointer.as_ref().map(|pointer| {
8859 json!({
8860 "seq": pointer.seq,
8861 "commit_hash": pointer.commit_hash,
8862 "content_root": pointer.content_root,
8863 "asset_root": pointer.asset_root,
8864 })
8865 });
8866 let entropy = format!(
8870 "{}\0{}\0{}\0{}\0{}\0{}",
8871 normalized_origin(&cfg.hub)?,
8872 head.brain_id,
8873 serde_json::to_string(&base_value).unwrap_or_default(),
8874 serde_json::to_string(&operations).unwrap_or_default(),
8875 serde_json::to_string(&withheld_links).unwrap_or_default(),
8876 checkout_id.as_deref().unwrap_or("")
8877 );
8878 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
8879 let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
8880 total
8881 .checked_add(source.bytes)
8882 .ok_or_else(|| LinkError::PushTooLarge {
8883 detail: "v2 changed-byte total overflow".to_string(),
8884 })
8885 })?;
8886 let inline = changed_bytes <= 3 * 1024 * 1024;
8887 let inline_blobs = if inline {
8888 upload_sources
8889 .iter()
8890 .map(|(sha256, source)| {
8891 let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
8892 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
8893 return Err(LinkError::InvalidPack {
8894 message: format!("local path `{}` changed before upload", source.path),
8895 });
8896 }
8897 Ok(json!({
8898 "sha256": sha256,
8899 "bytes": source.bytes,
8900 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
8901 }))
8902 })
8903 .collect::<LinkResult<Vec<_>>>()?
8904 } else {
8905 Vec::new()
8906 };
8907 let mut body = json!({
8908 "mutation_id": mutation_id,
8909 "base": base_value,
8910 "rebase": rebase,
8911 "reason": "dbmd sync",
8912 "operations": operations,
8913 "blobs": inline_blobs,
8914 });
8915 if !withheld_links.is_empty() {
8916 body["withheld_links"] = serde_json::to_value(&withheld_links)
8917 .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
8918 body["checkout_id"] =
8919 Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
8920 }
8921 if let Some(confirmation) = bulk_confirmation {
8922 if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
8923 return Err(LinkError::InvalidPack {
8924 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
8925 .to_string(),
8926 });
8927 }
8928 body["rebase"] = Value::String("strict".to_string());
8932 body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8933 body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8934 }
8935 if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8936 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8937 for operation in &operations {
8938 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8939 return Err(invalid_feed("v2 upload operation has no kind"));
8940 };
8941 let hash = match kind {
8942 "put" | "put_asset_content" | "restore" | "rename" => {
8943 operation.get("blob").and_then(Value::as_str)
8944 }
8945 "asset_put" | "asset_resume" => operation
8946 .get("asset")
8947 .and_then(|asset| asset.get("blob_sha256"))
8948 .and_then(Value::as_str),
8949 _ => None,
8950 };
8951 let Some(hash) = hash else { continue };
8952 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8953 if kind == "rename" {
8954 for field in ["from", "to"] {
8955 coordinates.insert(
8956 operation
8957 .get(field)
8958 .and_then(Value::as_str)
8959 .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8960 .to_string(),
8961 );
8962 }
8963 } else {
8964 let path = operation
8965 .get("path")
8966 .and_then(Value::as_str)
8967 .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8968 coordinates.insert(if kind.starts_with("asset_") {
8969 format!("assets/{path}")
8970 } else {
8971 path.to_string()
8972 });
8973 }
8974 }
8975 let declarations = upload_sources
8976 .iter()
8977 .map(|(sha256, source)| {
8978 json!({
8979 "sha256": sha256,
8980 "bytes": source.bytes,
8981 "coordinates": coordinates_by_hash
8982 .get(sha256)
8983 .into_iter()
8984 .flatten()
8985 .collect::<Vec<_>>(),
8986 })
8987 })
8988 .collect::<Vec<_>>();
8989 let mut references = Vec::with_capacity(upload_sources.len());
8990 let mut seen = std::collections::BTreeSet::new();
8991 let mut reserved_count = 0usize;
8992 let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8993 for batch in batch_upload_declarations(declarations) {
8997 let batch_len = batch.len();
8998 let reserved = reserve_upload_window(
8999 cfg,
9000 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
9001 &json!({ "blobs": batch }),
9002 "prepare v2 changed-byte uploads",
9003 )?;
9004 let items = reserved
9005 .get("uploads")
9006 .and_then(Value::as_array)
9007 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
9008 if items.len() != batch_len {
9009 return Err(invalid_feed(
9010 "v2 upload reservation response changed the requested set",
9011 ));
9012 }
9013 reserved_count += items.len();
9014 for item in items {
9015 let sha256 = item
9016 .get("sha256")
9017 .and_then(Value::as_str)
9018 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
9019 let source = upload_sources
9020 .get(sha256)
9021 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
9022 let declared_bytes = item
9023 .get("bytes")
9024 .and_then(Value::as_u64)
9025 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
9026 let reservation_id = item
9027 .get("reservation_id")
9028 .and_then(Value::as_str)
9029 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
9030 let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
9031 invalid_feed("v2 upload reservation has no coordinate binding")
9032 })?;
9033 let returned_coordinates = item
9034 .get("coordinates")
9035 .and_then(Value::as_array)
9036 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
9037 if declared_bytes != source.bytes
9038 || !crate::ulid::is_ulid(reservation_id)
9039 || !seen.insert(sha256.to_string())
9040 || returned_coordinates.len() != expected_coordinates.len()
9041 || returned_coordinates
9042 .iter()
9043 .zip(expected_coordinates)
9044 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
9045 {
9046 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
9047 }
9048 match item.get("status").and_then(Value::as_str) {
9049 Some("upload") => {
9050 let url = item
9051 .get("url")
9052 .and_then(Value::as_str)
9053 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
9054 pending_uploads.push(V2PendingUpload {
9055 url: url.to_string(),
9056 headers: item.get("headers").cloned().unwrap_or(Value::Null),
9057 sha256: sha256.to_string(),
9058 source,
9059 });
9060 }
9061 Some("already_present") => {}
9062 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
9063 }
9064 references.push(json!({
9065 "sha256": sha256,
9066 "bytes": source.bytes,
9067 "reservation_id": reservation_id,
9068 }));
9069 }
9070 upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
9076 pending_uploads.clear();
9077 }
9078 if reserved_count != upload_sources.len() {
9079 return Err(invalid_feed(
9080 "v2 upload reservation response changed the requested set",
9081 ));
9082 }
9083 body["blobs"] = Value::Array(references);
9084 }
9085 stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
9086 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
9087 let mut candidate_hub_signer: Option<String> = None;
9088 let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
9089 let bulk_preview_required = !(200..300).contains(&response.status)
9090 && response.body.as_ref().is_some_and(|value| {
9091 value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
9092 || value
9093 .get("details")
9094 .and_then(|details| details.get("code"))
9095 .and_then(Value::as_str)
9096 == Some("bulk_preview_required")
9097 });
9098 if bulk_preview_required && bulk_confirmation.is_none() {
9099 body["rebase"] = Value::String("strict".to_string());
9100 body["preview_only"] = Value::Bool(true);
9101 let preview = ensure_ok(
9102 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
9103 "v2 bulk preview",
9104 )?;
9105 let preview_code = preview.get("code").and_then(Value::as_str);
9106 let required = preview.get("required").and_then(Value::as_bool);
9107 if preview.get("v").and_then(Value::as_u64) != Some(2)
9108 || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
9109 || !matches!(
9110 preview_code,
9111 Some("bulk_preview_created" | "bulk_preview_not_required")
9112 )
9113 || required.is_none()
9114 {
9115 return Err(invalid_feed(
9116 "bulk preview response is not bound to the requested mutation",
9117 ));
9118 }
9119 if required == Some(true) {
9120 let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
9121 let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
9122 if preview_code != Some("bulk_preview_created")
9123 || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
9124 || preview_digest.is_none_or(|value| !is_sha256(value))
9125 || preview.get("expires_at").and_then(Value::as_str).is_none()
9126 || !preview.get("impact").is_some_and(Value::is_object)
9127 {
9128 return Err(invalid_feed("bulk preview receipt is malformed"));
9129 }
9130 return Err(LinkError::BulkPreviewRequired { preview });
9131 }
9132 if preview_code != Some("bulk_preview_not_required") {
9133 return Err(invalid_feed("bulk preview requirement is inconsistent"));
9134 }
9135 body.as_object_mut()
9138 .expect("v2 commit request is an object")
9139 .remove("preview_only");
9140 response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
9141 }
9142 let mut result = ensure_ok(response, "v2 sync push")?;
9143 if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
9144 if let Some(object) = result.as_object_mut() {
9145 object.insert(
9146 "sync_status".to_string(),
9147 Value::String("proposal_pending".to_string()),
9148 );
9149 }
9150 return Ok(result);
9151 }
9152 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
9153 let request_id = result
9154 .get("request_id")
9155 .and_then(Value::as_str)
9156 .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
9157 .to_string();
9158 let challenge = result
9159 .get("signing_challenge")
9160 .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
9161 let mut expected_candidate = remote.clone();
9162 let mut expected_candidate_assets = remote_assets.clone();
9163 apply_generated_v2_operations(
9164 &operations,
9165 &local_assets,
9166 &mut expected_candidate,
9167 &mut expected_candidate_assets,
9168 )?;
9169 verify_v2_markdown_asset_content_bindings(&expected_candidate, &expected_candidate_assets)?;
9170 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
9171 cfg,
9172 &head,
9173 &expected_candidate,
9174 &expected_candidate_assets,
9175 &mutation_id,
9176 &v2_signed_request_view(&body, &operations),
9177 challenge,
9178 )?;
9179 body["signing_challenge_id"] = Value::String(challenge_id);
9180 body["signature_base64url"] = Value::String(signature);
9181 candidate_hub_signer = Some(actor_signer);
9182 result = ensure_ok(
9183 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
9184 "v2 self-custody commit",
9185 )?;
9186 }
9187 let refreshed = v2_verified_head(cfg, requested_brain)?
9188 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
9189 if candidate_hub_signer
9190 .as_ref()
9191 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
9192 {
9193 return Err(invalid_feed(
9194 "self-custody actor signer differs from the committed hub pointer signer",
9195 ));
9196 }
9197 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
9198 if refreshed
9199 .pointer
9200 .as_ref()
9201 .map(|pointer| pointer.commit_hash.as_str())
9202 != accepted_hash
9203 {
9204 return Err(LinkError::RemoteAdvancedDuringSync);
9205 }
9206 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
9207 let rebased = result
9208 .get("rebased")
9209 .and_then(Value::as_bool)
9210 .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
9211 let (refreshed_files, refreshed_assets) = if rebased {
9212 (
9213 files_for_v2_view(
9214 &refreshed,
9215 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
9216 ),
9217 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
9218 )
9219 } else {
9220 let asset_changed = apply_generated_v2_operations(
9221 &operations,
9222 &local_assets,
9223 &mut remote,
9224 &mut remote_assets,
9225 )?;
9226 let assets = if asset_changed {
9227 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
9230 } else {
9231 remote_assets
9232 };
9233 (remote, assets)
9234 };
9235 verify_v2_markdown_asset_content_bindings(&refreshed_files, &refreshed_assets)?;
9236 let mut final_local = v2_local_files_cached(
9237 store,
9238 Some((&local_view.policy.digest, &local_view.scan_cache)),
9239 )?;
9240 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
9241 let final_assets = v2_local_asset_records(store)?;
9242 let local_dirty = final_local.riding != local_view.riding
9243 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
9244 final_local.policy.keeps_home(path)
9245 })
9246 || final_assets != local_assets
9247 || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
9248 let contract_handoff = contract_phase.then(|| V2PulledSnapshot {
9254 report: PullReport {
9255 brain: refreshed.brain_id.clone(),
9256 slug: requested_brain.to_string(),
9257 head_seq: refreshed.pointer.as_ref().map_or(0, |pointer| pointer.seq),
9258 files: refreshed_files.len(),
9259 dest: store.root.to_string_lossy().into_owned(),
9260 extra_local: Vec::new(),
9261 sync_status: "contract_phase".to_string(),
9262 },
9263 head: refreshed.clone(),
9264 files: refreshed_files.clone(),
9265 assets: refreshed_assets.clone(),
9266 local: final_local.clone(),
9267 local_assets: final_assets.clone(),
9268 });
9269 let next = v2_baseline_from_head(
9270 cfg,
9271 &refreshed,
9272 refreshed_files,
9273 refreshed_assets,
9274 Some(&final_local),
9275 Some(&checkout_pseudonym),
9276 )?;
9277 let split_count = next.remote_copy_remains.len();
9278 accept_v2_head(cfg, &refreshed)?;
9279 if !local_dirty {
9280 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
9281 }
9282 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
9287 if let Some(object) = result.as_object_mut() {
9288 object.insert(
9289 "local_policy".to_string(),
9290 json!({ "remote_copy_remains": split_count }),
9291 );
9292 object.insert(
9293 "sync_status".to_string(),
9294 Value::String(if local_dirty {
9295 "remote_committed_local_dirty".to_string()
9296 } else {
9297 "synced".to_string()
9298 }),
9299 );
9300 }
9301 if contract_phase && result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9302 let contract_receipt = result;
9303 drop(_operation_lock);
9308 let mut remaining = v2_sync_push(
9309 cfg,
9310 requested_brain,
9311 store,
9312 refreshed,
9313 V2SyncPushOptions {
9314 resume_local_policy,
9315 bulk_confirmation,
9316 resolution,
9317 pulled: contract_handoff,
9318 withdrawal_paths,
9319 withdrawal_reason,
9320 allow_contract_phase: false,
9321 },
9322 )?;
9323 if let Some(object) = remaining.as_object_mut() {
9324 object.insert("contract_phase".to_string(), contract_receipt);
9325 }
9326 return Ok(remaining);
9327 }
9328 if contract_phase {
9329 if let Some(object) = result.as_object_mut() {
9330 object.insert("contract_phase_pending".to_string(), Value::Bool(true));
9331 }
9332 }
9333 Ok(result)
9334}
9335
9336pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
9339 sync_push_incremental_with_policy(cfg, brain, store, false)
9340}
9341
9342pub fn sync_push_incremental_with_policy(
9345 cfg: &HubConfig,
9346 brain: &str,
9347 store: &Store,
9348 resume_local_policy: bool,
9349) -> LinkResult<Value> {
9350 sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
9351}
9352
9353pub fn sync_push_incremental_with_options(
9356 cfg: &HubConfig,
9357 brain: &str,
9358 store: &Store,
9359 resume_local_policy: bool,
9360 bulk_confirmation: Option<&V2BulkConfirmation>,
9361) -> LinkResult<Value> {
9362 sync_push_incremental_with_controls(
9363 cfg,
9364 brain,
9365 store,
9366 resume_local_policy,
9367 bulk_confirmation,
9368 &[],
9369 None,
9370 )
9371}
9372
9373pub fn sync_push_incremental_with_controls(
9375 cfg: &HubConfig,
9376 brain: &str,
9377 store: &Store,
9378 resume_local_policy: bool,
9379 bulk_confirmation: Option<&V2BulkConfirmation>,
9380 withdrawal_paths: &[String],
9381 withdrawal_reason: Option<&str>,
9382) -> LinkResult<Value> {
9383 require_safe_ref(brain)?;
9384 if let Some(head) = v2_verified_head(cfg, brain)? {
9385 return v2_sync_push(
9386 cfg,
9387 brain,
9388 store,
9389 head,
9390 V2SyncPushOptions {
9391 resume_local_policy,
9392 bulk_confirmation,
9393 resolution: None,
9394 pulled: None,
9395 withdrawal_paths,
9396 withdrawal_reason,
9397 allow_contract_phase: true,
9398 },
9399 );
9400 }
9401 if !withdrawal_paths.is_empty() {
9402 return Err(LinkError::InvalidPack {
9403 message: "explicit withdrawal requires a link.md v2 brain".to_string(),
9404 });
9405 }
9406 legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
9407}
9408
9409pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
9413 require_safe_ref(brain)?;
9414 Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
9415}
9416
9417pub fn relocate_v2_sync_baseline(
9423 cfg: &HubConfig,
9424 brain: &str,
9425 from: &Path,
9426 to: &Path,
9427) -> LinkResult<Value> {
9428 require_hardened_filesystem("verified link.md v2 baseline relocation")?;
9429 require_safe_ref(brain)?;
9430 if !crate::ulid::is_ulid(brain) {
9431 return Err(invalid_feed(
9432 "v2 baseline relocation requires the canonical brain id",
9433 ));
9434 }
9435 let from_absolute = if from.is_absolute() {
9436 from.to_path_buf()
9437 } else {
9438 std::env::current_dir()?.join(from)
9439 };
9440 let to_absolute = if to.is_absolute() {
9441 to.to_path_buf()
9442 } else {
9443 std::env::current_dir()?.join(to)
9444 };
9445 let source_name = v2_baseline_name(cfg, brain, &from_absolute)?;
9446 let target_name = v2_baseline_name(cfg, brain, &to_absolute)?;
9447 if source_name == target_name {
9448 return Err(invalid_feed(
9449 "v2 baseline relocation source and destination are the same checkout",
9450 ));
9451 }
9452 match std::fs::symlink_metadata(&from_absolute) {
9453 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
9454 Err(error) => return Err(error.into()),
9455 Ok(_) => {
9456 return Err(LinkError::InvalidPack {
9457 message: "the old checkout still exists; move it before relocating its baseline"
9458 .to_string(),
9459 })
9460 }
9461 }
9462 let store = Store::open_strict(&to_absolute).map_err(|error| LinkError::InvalidPack {
9463 message: format!("relocated checkout is not a valid db.md store: {error}"),
9464 })?;
9465 let _operation_lock = lock_v2_sync_operation(cfg, brain)?;
9466
9467 #[cfg(any(unix, windows))]
9468 {
9469 let directory = open_trust_dir(cfg)?;
9470 let mut lock_names = [source_name.as_str(), target_name.as_str()];
9471 lock_names.sort();
9472 let _locks = lock_names
9473 .iter()
9474 .map(|name| lock_trust_name(&directory, name))
9475 .collect::<LinkResult<Vec<_>>>()?;
9476 let source = load_v2_baseline_in(cfg, brain, &directory, &source_name)?;
9477 let target = load_v2_baseline_in(cfg, brain, &directory, &target_name)?;
9478 let (baseline, already_relocated) = match (source, target) {
9479 (Some(source), None) => (source, false),
9480 (None, Some(target)) => (target, true),
9481 (Some(_), Some(_)) => {
9482 return Err(LinkError::InvalidPack {
9483 message: "both old and new checkout paths already have private sync baselines"
9484 .to_string(),
9485 })
9486 }
9487 (None, None) => {
9488 return Err(LinkError::InvalidPack {
9489 message: "the old checkout has no verified incremental baseline to relocate"
9490 .to_string(),
9491 })
9492 }
9493 };
9494
9495 let mut local = v2_local_files(&store)?;
9496 if baseline.view_kind.as_deref() == Some("scoped") {
9497 let expected = baseline
9498 .projection_sha256
9499 .as_deref()
9500 .ok_or_else(|| invalid_feed("scoped baseline has no projection hash"))?;
9501 if local.riding.get("DB.md").map(|value| value.0.as_str()) != Some(expected) {
9502 return Err(LinkError::ScopedProjectionModified);
9503 }
9504 local.riding.remove("DB.md");
9505 }
9506 if baseline.local_policy_digest.as_deref() != Some(local.policy.digest.as_str())
9507 || !v2_riding_matches_remote(&local.riding, &baseline.files, |path| {
9508 local.policy.keeps_home(path)
9509 })
9510 || !v2_asset_records_match_remote(&v2_local_asset_records(&store)?, &baseline.assets)
9511 {
9512 return Err(LinkError::InvalidPack {
9513 message: "the moved checkout no longer matches its verified incremental baseline"
9514 .to_string(),
9515 });
9516 }
9517 if !already_relocated {
9518 crate::fsx::rename_beneath(
9519 &directory,
9520 Path::new(&source_name),
9521 Path::new(&target_name),
9522 )?;
9523 directory.sync_all()?;
9524 }
9525 Ok(json!({
9526 "v": 2,
9527 "class": "checkout_baseline_relocated",
9528 "brain": baseline.brain,
9529 "from": from_absolute,
9530 "to": to_absolute,
9531 "headSeq": baseline.head_seq.unwrap_or(0),
9532 "commitHash": baseline.commit_hash,
9533 "moved": !already_relocated,
9534 }))
9535 }
9536
9537 #[cfg(not(any(unix, windows)))]
9538 Err(LinkError::UnsupportedPlatform {
9539 operation: "verified link.md v2 baseline relocation",
9540 })
9541}
9542
9543#[cfg(windows)]
9544fn legacy_sync_push_incremental(
9545 _cfg: &HubConfig,
9546 _brain: &str,
9547 _store: &Store,
9548 _resume_local_policy: bool,
9549 _bulk_confirmation: Option<&V2BulkConfirmation>,
9550) -> LinkResult<Value> {
9551 Err(LinkError::UnsupportedPlatform {
9552 operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
9553 })
9554}
9555
9556#[cfg(not(windows))]
9557fn legacy_sync_push_incremental(
9558 cfg: &HubConfig,
9559 brain: &str,
9560 store: &Store,
9561 resume_local_policy: bool,
9562 bulk_confirmation: Option<&V2BulkConfirmation>,
9563) -> LinkResult<Value> {
9564 if resume_local_policy || bulk_confirmation.is_some() {
9565 return Err(LinkError::InvalidPack {
9566 message: "v2 sync options require a link.md v2 brain".to_string(),
9567 });
9568 }
9569 let files = collect_push_files(store)?;
9570 sync_push(cfg, brain, &files)
9571}
9572
9573#[derive(Debug, Clone)]
9575pub enum V2ConflictChoice {
9576 KeepLocal,
9577 TakeRemote,
9578 From(PathBuf),
9579}
9580
9581fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
9582 if !crate::ulid::is_ulid(bundle) {
9583 return Err(LinkError::InvalidPack {
9584 message: "conflict bundle must be a lowercase ULID".to_string(),
9585 });
9586 }
9587 let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
9588 let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
9589 .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
9590 if plan.v != 2
9591 || plan.class != "content_resolution_required"
9592 || plan.bundle != bundle
9593 || !crate::ulid::is_ulid(&plan.brain)
9594 || plan.files.is_empty()
9595 || plan.files.len() > 100
9596 || plan.files.iter().any(|file| {
9597 crate::linkmd_v2::normalize_path(&file.path).is_err()
9598 || [&file.base, &file.local, &file.remote]
9599 .into_iter()
9600 .any(|coordinate| {
9601 coordinate
9602 .sha256
9603 .as_deref()
9604 .is_some_and(|hash| !is_sha256(hash))
9605 || coordinate.file.as_deref().is_some_and(|name| {
9606 name.starts_with('/')
9607 || name
9608 .split('/')
9609 .any(|part| part.is_empty() || part == "." || part == "..")
9610 })
9611 })
9612 })
9613 {
9614 return Err(invalid_feed("private conflict plan failed validation"));
9615 }
9616 Ok(plan)
9617}
9618
9619pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
9624 require_hardened_filesystem("private conflict maintenance")?;
9625 if all && !prune {
9626 return Err(LinkError::InvalidPack {
9627 message: "discarding all conflict bundles requires prune=true".to_string(),
9628 });
9629 }
9630 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9631 message: format!("conflict checkout is not a valid db.md store: {error}"),
9632 })?;
9633 let _transaction = store.transaction()?;
9634 let root = Path::new(".dbmd/conflicts");
9635 let names = match store.directory_names(root) {
9636 Ok(names) => names,
9637 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
9638 Err(error) => return Err(error.into()),
9639 };
9640 let now = SystemTime::now()
9641 .duration_since(UNIX_EPOCH)
9642 .unwrap_or_default()
9643 .as_secs();
9644 let mut bundles = Vec::new();
9645 let mut pruned = 0_u64;
9646 for name in names {
9647 let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
9648 continue;
9649 };
9650 let plan_path = v2_conflict_relative(bundle, "plan.json");
9651 let plan_exists = store.regular_file_exists(&plan_path)?;
9652 let expired = if plan_exists {
9653 match load_v2_conflict_plan(&store, bundle) {
9654 Ok(plan) => plan.expires_unix < now,
9655 Err(error) if all => {
9656 let _ = error;
9657 true
9658 }
9659 Err(error) => return Err(error),
9660 }
9661 } else {
9662 true
9663 };
9664 if prune && (all || expired) {
9665 store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9666 pruned += 1;
9667 continue;
9668 }
9669 bundles.push(json!({
9670 "bundle": bundle,
9671 "complete": plan_exists,
9672 "expired": expired,
9673 }));
9674 }
9675 Ok(json!({
9676 "v": 2,
9677 "class": "private_conflict_state",
9678 "bundles": bundles.len(),
9679 "pruned": pruned,
9680 "items": bundles,
9681 }))
9682}
9683
9684pub fn sync_resolve_conflict(
9688 cfg: &HubConfig,
9689 checkout: &Path,
9690 bundle: &str,
9691 choice: V2ConflictChoice,
9692 bulk_confirmation: Option<&V2BulkConfirmation>,
9693) -> LinkResult<Value> {
9694 require_hardened_filesystem("conflict resolution")?;
9695 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9696 message: format!("conflict checkout is not a valid db.md store: {error}"),
9697 })?;
9698 let plan = load_v2_conflict_plan(&store, bundle)?;
9699 if plan.origin != normalized_origin(&cfg.hub)? {
9700 return Err(invalid_feed(
9701 "conflict bundle belongs to another hub origin",
9702 ));
9703 }
9704 let now = SystemTime::now()
9705 .duration_since(UNIX_EPOCH)
9706 .unwrap_or_default()
9707 .as_secs();
9708 if now > plan.expires_unix {
9709 return Err(LinkError::InvalidPack {
9710 message: "conflict bundle expired; rerun sync to obtain current coordinates"
9711 .to_string(),
9712 });
9713 }
9714 let head = v2_verified_head(cfg, &plan.brain)?
9715 .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
9716 let pointer = head.pointer.as_ref();
9717 if pointer.map_or(0, |value| value.seq) != plan.remote_seq
9718 || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
9719 || pointer.and_then(|value| value.content_root.as_deref())
9720 != plan.remote_content_root.as_deref()
9721 || head.view_kind != plan.view_kind
9722 || head.view_revision != plan.view_revision
9723 {
9724 return Err(LinkError::RemoteAdvancedDuringSync);
9725 }
9726
9727 for file in &plan.files {
9729 let actual = match store.regular_file_exists(Path::new(&file.path))? {
9730 true => Some(content_sha256(&store.read_bounded(
9731 Path::new(&file.path),
9732 file.local.bytes.unwrap_or(MAX_STORE_BYTES),
9733 )?)),
9734 false => None,
9735 };
9736 if actual.as_deref() != file.local.sha256.as_deref() {
9737 return Err(LinkError::InvalidPack {
9738 message: format!(
9739 "local conflict path `{}` changed after the bundle was created",
9740 file.path
9741 ),
9742 });
9743 }
9744 }
9745
9746 let from_source = match &choice {
9747 V2ConflictChoice::From(source) => Some(source.clone()),
9748 _ => None,
9749 };
9750 let result = match choice {
9751 V2ConflictChoice::TakeRemote => {
9752 if bulk_confirmation.is_some() {
9753 return Err(LinkError::InvalidPack {
9754 message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
9755 });
9756 }
9757 let current_remote =
9761 files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
9762 let _ = v2_take_remote_selection(&plan.files, ¤t_remote)?;
9763 let selected = plan
9764 .files
9765 .iter()
9766 .map(|file| file.path.clone())
9767 .collect::<std::collections::BTreeSet<_>>();
9768 serde_json::to_value(
9769 v2_sync_pull_with_resolution(
9770 cfg,
9771 &plan.brain,
9772 head,
9773 Some(checkout),
9774 Some(&selected),
9775 )?
9776 .report,
9777 )
9778 .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
9779 }
9780 V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
9781 if let Some(source) = from_source.as_ref() {
9782 if plan.files.len() != 1 {
9783 return Err(LinkError::InvalidPack {
9784 message: "--from requires a bundle with exactly one conflict".to_string(),
9785 });
9786 }
9787 let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
9788 if std::str::from_utf8(&candidate).is_err() {
9789 return Err(LinkError::NotUtf8 {
9790 path: source.display().to_string(),
9791 });
9792 }
9793 store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
9794 }
9795 let refreshed_store =
9796 Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9797 message: format!("resolved checkout is not a valid db.md store: {error}"),
9798 })?;
9799 let mut overrides = std::collections::BTreeMap::new();
9800 for file in &plan.files {
9801 let selected_local = match refreshed_store
9802 .regular_file_exists(Path::new(&file.path))?
9803 {
9804 true => Some(content_sha256(
9805 &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
9806 )),
9807 false => None,
9808 };
9809 overrides.insert(
9810 file.path.clone(),
9811 V2ResolutionOverride {
9812 expected_remote: file.remote.sha256.clone(),
9813 selected_local,
9814 },
9815 );
9816 }
9817 v2_sync_push(
9818 cfg,
9819 &plan.brain,
9820 &refreshed_store,
9821 head,
9822 V2SyncPushOptions {
9823 resume_local_policy: true,
9824 bulk_confirmation,
9825 resolution: Some(&overrides),
9826 pulled: None,
9827 withdrawal_paths: &[],
9828 withdrawal_reason: None,
9829 allow_contract_phase: true,
9830 },
9831 )?
9832 }
9833 };
9834
9835 if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9836 let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9837 message: format!("resolved checkout is not a valid db.md store: {error}"),
9838 })?;
9839 installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9840 }
9841 Ok(json!({
9842 "v": 2,
9843 "class": "auto_converged",
9844 "bundle": bundle,
9845 "receipt": result,
9846 }))
9847}
9848
9849pub fn sync_converge(
9860 cfg: &HubConfig,
9861 brain: &str,
9862 checkout: &Path,
9863 resume_local_policy: bool,
9864) -> LinkResult<Value> {
9865 sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
9866}
9867
9868pub fn sync_converge_with_options(
9870 cfg: &HubConfig,
9871 brain: &str,
9872 checkout: &Path,
9873 resume_local_policy: bool,
9874 bulk_confirmation: Option<&V2BulkConfirmation>,
9875) -> LinkResult<Value> {
9876 sync_converge_with_controls(
9877 cfg,
9878 brain,
9879 checkout,
9880 resume_local_policy,
9881 bulk_confirmation,
9882 &[],
9883 None,
9884 )
9885}
9886
9887pub fn sync_converge_with_controls(
9889 cfg: &HubConfig,
9890 brain: &str,
9891 checkout: &Path,
9892 resume_local_policy: bool,
9893 bulk_confirmation: Option<&V2BulkConfirmation>,
9894 withdrawal_paths: &[String],
9895 withdrawal_reason: Option<&str>,
9896) -> LinkResult<Value> {
9897 require_hardened_filesystem("bidirectional sync")?;
9898 require_safe_ref(brain)?;
9899 let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
9900 message:
9901 "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
9902 .to_string(),
9903 })?;
9904 let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
9905 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9906 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9907 })?;
9908 let _transaction = store.transaction()?;
9909 let pulled_report = pulled.report.clone();
9910 let pulled_head = pulled.head.clone();
9911 let mut result = v2_sync_push(
9912 cfg,
9913 brain,
9914 &store,
9915 pulled_head,
9916 V2SyncPushOptions {
9917 resume_local_policy,
9918 bulk_confirmation,
9919 resolution: None,
9920 pulled: Some(pulled),
9921 withdrawal_paths,
9922 withdrawal_reason,
9923 allow_contract_phase: true,
9924 },
9925 )?;
9926 if let Some(object) = result.as_object_mut() {
9927 object.insert("pulled_files".to_string(), json!(pulled_report.files));
9928 object.insert("checkout".to_string(), Value::String(pulled_report.dest));
9929 object.insert(
9930 "mode".to_string(),
9931 Value::String("bidirectional".to_string()),
9932 );
9933 }
9934 Ok(result)
9935}
9936
9937pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9943 require_hardened_filesystem("sync pull")?;
9944 require_safe_ref(brain)?;
9945 if let Some(head) = v2_verified_head(cfg, brain)? {
9946 return v2_sync_pull(cfg, brain, head, out);
9947 }
9948 legacy_sync_pull(cfg, brain, out)
9949}
9950
9951#[cfg(windows)]
9952fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
9953 Err(LinkError::UnsupportedPlatform {
9954 operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
9955 })
9956}
9957
9958#[cfg(not(windows))]
9959fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9960 let remote = verified_remote_head(cfg, brain, false)?;
9961 if !remote.head.verified {
9962 return Err(invalid_feed(
9963 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
9964 ));
9965 }
9966 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
9967 let path = format!(
9968 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
9969 remote.head.seq
9970 );
9971 let body = ensure_ok(
9972 request(cfg, "GET", &path, None, Auth::Required)?,
9973 "sync pull",
9974 )?;
9975 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
9976 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
9977 {
9978 return Err(invalid_feed(
9979 "export response is not bound to the verified snapshot token",
9980 ));
9981 }
9982
9983 let remote_slug = body
9984 .get("slug")
9985 .and_then(Value::as_str)
9986 .filter(|slug| is_safe_slug(slug));
9987 let slug = remote_slug
9988 .or_else(|| is_safe_slug(brain).then_some(brain))
9989 .unwrap_or("brain")
9990 .to_string();
9991 let brain_id = body
9992 .get("brain")
9993 .and_then(Value::as_str)
9994 .unwrap_or(&remote.head.brain)
9995 .to_string();
9996 if brain_id != remote.head.brain {
9997 return Err(invalid_feed(
9998 "export response names a different brain than the verified head",
9999 ));
10000 }
10001 let head_seq = remote.head.seq;
10002 let dest: PathBuf = match out {
10003 Some(p) => p.to_path_buf(),
10004 None => PathBuf::from(&slug),
10005 };
10006 let entries = if head_seq == 0 {
10007 let files = body
10008 .get("files")
10009 .and_then(Value::as_array)
10010 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
10011 if !files.is_empty() || body.get("url").is_some() {
10012 return Err(invalid_feed(
10013 "empty signed feed cannot authorize non-empty exported content",
10014 ));
10015 }
10016 Vec::new()
10017 } else {
10018 let signed_head = remote
10019 .head_entry
10020 .as_ref()
10021 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
10022 let expected = &signed_head.entry.pack_sha256;
10023 if !is_sha256(expected) {
10024 return Err(invalid_feed(
10025 "signed head carries an invalid snapshot pack digest",
10026 ));
10027 }
10028 if let Some(url) = body.get("url").and_then(Value::as_str) {
10029 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
10030 return Err(invalid_feed(
10031 "export pack digest does not match the signed head entry",
10032 ));
10033 }
10034 let bytes = get_presigned(cfg, url)?;
10035 let actual = format!("{:x}", Sha256::digest(&bytes));
10036 if actual != *expected {
10037 return Err(LinkError::InvalidPack {
10038 message: "downloaded pack does not match the signed snapshot digest"
10039 .to_string(),
10040 });
10041 }
10042 let entries = parse_store_pack(bytes)?;
10043 if signed_head.entry.kind == "push" {
10044 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
10045 }
10046 entries
10047 } else {
10048 if signed_head.entry.kind != "push" {
10049 return Err(invalid_feed(
10050 "delta snapshots must export the exact signed pack",
10051 ));
10052 }
10053 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
10054 invalid_feed("verified snapshot export carried neither a pack nor files")
10055 })?;
10056 let mut entries = Vec::with_capacity(files.len());
10057 for file in files {
10058 let path = file
10059 .get("path")
10060 .and_then(Value::as_str)
10061 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
10062 let content = file
10063 .get("content")
10064 .and_then(Value::as_str)
10065 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
10066 entries.push((path.to_string(), content.as_bytes().to_vec()));
10067 }
10068 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
10069 entries
10070 }
10071 };
10072
10073 let mut seen = std::collections::HashSet::new();
10075 for (path, _) in &entries {
10076 if !safe_store_rel_path(path) {
10077 return Err(LinkError::UnsafePath { path: path.clone() });
10078 }
10079 if !seen.insert(path) {
10080 return Err(LinkError::InvalidPack {
10081 message: format!("duplicate path `{path}`"),
10082 });
10083 }
10084 }
10085 let pulled: std::collections::BTreeSet<&str> =
10088 entries.iter().map(|(p, _)| p.as_str()).collect();
10089 let mut extra_local = Vec::new();
10090 if let Ok(store) = Store::open(&dest) {
10091 if let Ok(walked) = store.walk() {
10092 for rel in walked {
10093 let rel_str = rel.to_string_lossy().replace('\\', "/");
10094 if !pulled.contains(rel_str.as_str()) {
10095 extra_local.push(rel_str);
10096 }
10097 }
10098 }
10099 }
10100 #[cfg(unix)]
10101 install_pulled_snapshot(&dest, &entries)?;
10102
10103 Ok(PullReport {
10104 brain: brain_id,
10105 slug,
10106 head_seq,
10107 files: entries.len(),
10108 dest: dest.to_string_lossy().into_owned(),
10109 extra_local,
10110 sync_status: "synced".to_string(),
10111 })
10112}
10113
10114#[cfg(unix)]
10115fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
10116 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
10117 path: display.to_string(),
10118 })
10119}
10120
10121#[cfg(unix)]
10122fn open_dir_at(
10123 parent: std::os::fd::RawFd,
10124 name: &std::ffi::CStr,
10125 display: &str,
10126) -> LinkResult<std::fs::File> {
10127 use std::os::fd::FromRawFd as _;
10128 let fd = unsafe {
10129 libc::openat(
10130 parent,
10131 name.as_ptr(),
10132 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10133 )
10134 };
10135 if fd < 0 {
10136 return Err(LinkError::UnsafePath {
10137 path: display.to_string(),
10138 });
10139 }
10140 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
10141}
10142
10143#[cfg(unix)]
10147fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
10148 use std::os::fd::AsRawFd as _;
10149
10150 #[cfg(target_os = "macos")]
10154 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
10155 .into_iter()
10156 .find_map(|(alias, real)| {
10157 path.strip_prefix(alias)
10158 .ok()
10159 .map(|rest| Path::new(real).join(rest))
10160 })
10161 .unwrap_or_else(|| path.to_path_buf());
10162 #[cfg(not(target_os = "macos"))]
10163 let normalized = path.to_path_buf();
10164
10165 let start = if normalized.is_absolute() {
10166 std::fs::File::open("/")?
10167 } else {
10168 std::fs::File::open(".")?
10169 };
10170 let mut directory = start;
10171 for component in normalized.components() {
10172 use std::path::Component;
10173 let name = match component {
10174 Component::RootDir | Component::CurDir => continue,
10175 Component::Normal(name) => name,
10176 Component::ParentDir | Component::Prefix(_) => {
10177 return Err(LinkError::UnsafePath {
10178 path: path.display().to_string(),
10179 });
10180 }
10181 };
10182 use std::os::unix::ffi::OsStrExt as _;
10183 let name = c_name(name.as_bytes(), &path.display().to_string())?;
10184 if create {
10185 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10186 if made != 0 {
10187 let error = std::io::Error::last_os_error();
10188 if error.raw_os_error() != Some(libc::EEXIST) {
10189 return Err(error.into());
10190 }
10191 }
10192 }
10193 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
10194 }
10195 Ok(directory)
10196}
10197
10198#[cfg(unix)]
10199fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
10200 open_dir_path_nofollow(path, true)
10201}
10202
10203#[cfg(unix)]
10204fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
10205 open_dir_path_nofollow(path, false)
10206}
10207
10208#[cfg(unix)]
10209fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
10210 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
10211 let result =
10212 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
10213 if result == 0 {
10214 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
10215 }
10216 let error = std::io::Error::last_os_error();
10217 if error.kind() == std::io::ErrorKind::NotFound {
10218 Ok(None)
10219 } else {
10220 Err(error.into())
10221 }
10222}
10223
10224#[cfg(unix)]
10225fn create_dir_exclusive_at(
10226 parent: std::os::fd::RawFd,
10227 name: &std::ffi::CStr,
10228 display: &str,
10229) -> LinkResult<std::fs::File> {
10230 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
10231 if made != 0 {
10232 return Err(LinkError::UnsafePath {
10233 path: display.to_string(),
10234 });
10235 }
10236 open_dir_at(parent, name, display)
10237}
10238
10239#[cfg(unix)]
10240fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
10241 use std::os::fd::AsRawFd as _;
10242
10243 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
10244 if duplicate < 0 {
10245 return Err(std::io::Error::last_os_error().into());
10246 }
10247 let stream = unsafe { libc::fdopendir(duplicate) };
10248 if stream.is_null() {
10249 let error = std::io::Error::last_os_error();
10250 unsafe {
10251 libc::close(duplicate);
10252 }
10253 return Err(error.into());
10254 }
10255 let mut names = Vec::new();
10256 loop {
10257 let entry = unsafe { libc::readdir(stream) };
10258 if entry.is_null() {
10259 break;
10260 }
10261 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
10262 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
10263 names.push(raw.to_owned());
10264 }
10265 }
10266 if unsafe { libc::closedir(stream) } != 0 {
10267 return Err(std::io::Error::last_os_error().into());
10268 }
10269 Ok(names)
10270}
10271
10272#[cfg(unix)]
10275fn remove_tree_at(
10276 parent: std::os::fd::RawFd,
10277 name: &std::ffi::CStr,
10278 display: &str,
10279) -> LinkResult<()> {
10280 use std::os::fd::AsRawFd as _;
10281
10282 match entry_is_dir_at(parent, name)? {
10283 None => return Ok(()),
10284 Some(false) => {
10285 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
10286 return Err(std::io::Error::last_os_error().into());
10287 }
10288 }
10289 Some(true) => {
10290 let directory = open_dir_at(parent, name, display)?;
10291 for child in directory_entry_names(&directory)? {
10292 let child_display =
10293 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
10294 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
10295 }
10296 drop(directory);
10297 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
10298 return Err(std::io::Error::last_os_error().into());
10299 }
10300 }
10301 }
10302 Ok(())
10303}
10304
10305#[cfg(unix)]
10309fn clone_tree_contents(
10310 source: &std::fs::File,
10311 destination: &std::fs::File,
10312 display: &str,
10313) -> LinkResult<()> {
10314 use std::os::fd::{AsRawFd as _, FromRawFd as _};
10315
10316 for name in directory_entry_names(source)? {
10317 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
10318 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
10319 if unsafe {
10320 libc::fstatat(
10321 source.as_raw_fd(),
10322 name.as_ptr(),
10323 &mut stat,
10324 libc::AT_SYMLINK_NOFOLLOW,
10325 )
10326 } != 0
10327 {
10328 return Err(std::io::Error::last_os_error().into());
10329 }
10330 match stat.st_mode & libc::S_IFMT {
10331 libc::S_IFDIR => {
10332 if unsafe {
10333 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
10334 } != 0
10335 {
10336 return Err(std::io::Error::last_os_error().into());
10337 }
10338 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
10339 let destination_child =
10340 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
10341 clone_tree_contents(&source_child, &destination_child, &child_display)?;
10342 destination_child.sync_all()?;
10343 }
10344 libc::S_IFREG => {
10345 let source_fd = unsafe {
10346 libc::openat(
10347 source.as_raw_fd(),
10348 name.as_ptr(),
10349 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10350 )
10351 };
10352 if source_fd < 0 {
10353 return Err(std::io::Error::last_os_error().into());
10354 }
10355 let destination_fd = unsafe {
10356 libc::openat(
10357 destination.as_raw_fd(),
10358 name.as_ptr(),
10359 libc::O_WRONLY
10360 | libc::O_CREAT
10361 | libc::O_EXCL
10362 | libc::O_CLOEXEC
10363 | libc::O_NOFOLLOW,
10364 (stat.st_mode & 0o777) as libc::c_uint,
10365 )
10366 };
10367 if destination_fd < 0 {
10368 unsafe {
10369 libc::close(source_fd);
10370 }
10371 return Err(std::io::Error::last_os_error().into());
10372 }
10373 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
10374 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
10375 std::io::copy(&mut input, &mut output)?;
10376 output.sync_all()?;
10377 }
10378 libc::S_IFLNK => {
10379 let mut target = vec![0_u8; 4097];
10380 let length = unsafe {
10381 libc::readlinkat(
10382 source.as_raw_fd(),
10383 name.as_ptr(),
10384 target.as_mut_ptr().cast(),
10385 target.len(),
10386 )
10387 };
10388 if length < 0 || length as usize >= target.len() {
10389 return Err(LinkError::UnsafePath {
10390 path: child_display,
10391 });
10392 }
10393 target.truncate(length as usize);
10394 let target = c_name(&target, &child_display)?;
10395 if unsafe {
10396 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
10397 } != 0
10398 {
10399 return Err(std::io::Error::last_os_error().into());
10400 }
10401 }
10402 _ => {
10403 return Err(LinkError::UnsafePath {
10404 path: child_display,
10405 });
10406 }
10407 }
10408 }
10409 destination.sync_all()?;
10410 Ok(())
10411}
10412
10413#[cfg(target_os = "linux")]
10414fn install_stage_at(
10415 parent: std::os::fd::RawFd,
10416 stage: &std::ffi::CStr,
10417 dest: &std::ffi::CStr,
10418 dest_exists: bool,
10419) -> LinkResult<()> {
10420 let flags = if dest_exists {
10421 libc::RENAME_EXCHANGE
10422 } else {
10423 libc::RENAME_NOREPLACE
10424 };
10425 let result = unsafe {
10429 libc::syscall(
10430 libc::SYS_renameat2,
10431 parent,
10432 stage.as_ptr(),
10433 parent,
10434 dest.as_ptr(),
10435 flags,
10436 )
10437 };
10438 if result == 0 {
10439 Ok(())
10440 } else {
10441 Err(std::io::Error::last_os_error().into())
10442 }
10443}
10444
10445#[cfg(target_os = "macos")]
10446fn install_stage_at(
10447 parent: std::os::fd::RawFd,
10448 stage: &std::ffi::CStr,
10449 dest: &std::ffi::CStr,
10450 dest_exists: bool,
10451) -> LinkResult<()> {
10452 let flags = if dest_exists {
10453 libc::RENAME_SWAP
10454 } else {
10455 libc::RENAME_EXCL
10456 };
10457 let result =
10458 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
10459 if result == 0 {
10460 Ok(())
10461 } else {
10462 Err(std::io::Error::last_os_error().into())
10463 }
10464}
10465
10466#[cfg(unix)]
10467fn write_pull_entries_beneath_dir(
10468 root: &std::fs::File,
10469 entries: &[(String, Vec<u8>)],
10470) -> LinkResult<()> {
10471 use std::os::fd::{AsRawFd as _, FromRawFd as _};
10472
10473 for (path, content) in entries {
10474 let components: Vec<&str> = path.split('/').collect();
10475 let (leaf, parents) = components
10476 .split_last()
10477 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10478 let mut directory = root.try_clone()?;
10479 for component in parents {
10480 let name = c_name(component.as_bytes(), path)?;
10481 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10482 if made != 0 {
10483 let error = std::io::Error::last_os_error();
10484 if error.raw_os_error() != Some(libc::EEXIST) {
10485 return Err(error.into());
10486 }
10487 }
10488 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10489 }
10490
10491 let leaf_name = c_name(leaf.as_bytes(), path)?;
10492 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
10493 let inspected = unsafe {
10494 libc::fstatat(
10495 directory.as_raw_fd(),
10496 leaf_name.as_ptr(),
10497 &mut existing,
10498 libc::AT_SYMLINK_NOFOLLOW,
10499 )
10500 };
10501 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
10502 return Err(LinkError::UnsafePath { path: path.clone() });
10503 }
10504
10505 let nonce = std::time::SystemTime::now()
10506 .duration_since(std::time::UNIX_EPOCH)
10507 .unwrap_or_default()
10508 .as_nanos();
10509 let temp_name = format!(
10510 ".dbmd-pull-{}-{nonce}-{}",
10511 std::process::id(),
10512 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
10513 );
10514 let temp = c_name(temp_name.as_bytes(), path)?;
10515 let fd = unsafe {
10516 libc::openat(
10517 directory.as_raw_fd(),
10518 temp.as_ptr(),
10519 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10520 0o600,
10521 )
10522 };
10523 if fd < 0 {
10524 return Err(std::io::Error::last_os_error().into());
10525 }
10526 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
10527 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
10528 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10529 return Err(error.into());
10530 }
10531 drop(file);
10532 let renamed = unsafe {
10533 libc::renameat(
10534 directory.as_raw_fd(),
10535 temp.as_ptr(),
10536 directory.as_raw_fd(),
10537 leaf_name.as_ptr(),
10538 )
10539 };
10540 if renamed != 0 {
10541 let error = std::io::Error::last_os_error();
10542 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10543 return Err(error.into());
10544 }
10545 directory.sync_all()?;
10546 }
10547 root.sync_all()?;
10548 Ok(())
10549}
10550
10551#[cfg(unix)]
10552fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
10553 use std::os::fd::{AsRawFd as _, FromRawFd as _};
10554
10555 let path = &entry.path;
10556 let components: Vec<&str> = path.split('/').collect();
10557 let (leaf, parents) = components
10558 .split_last()
10559 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10560 let mut directory = root.try_clone()?;
10561 for component in parents {
10562 let name = c_name(component.as_bytes(), path)?;
10563 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10564 if made != 0 {
10565 let error = std::io::Error::last_os_error();
10566 if error.raw_os_error() != Some(libc::EEXIST) {
10567 return Err(error.into());
10568 }
10569 }
10570 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10571 }
10572 let leaf_name = c_name(leaf.as_bytes(), path)?;
10573 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
10574 if unsafe {
10575 libc::fstatat(
10576 directory.as_raw_fd(),
10577 leaf_name.as_ptr(),
10578 &mut existing,
10579 libc::AT_SYMLINK_NOFOLLOW,
10580 )
10581 } == 0
10582 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
10583 {
10584 return Err(LinkError::UnsafePath { path: path.clone() });
10585 }
10586 let nonce = SystemTime::now()
10587 .duration_since(UNIX_EPOCH)
10588 .unwrap_or_default()
10589 .as_nanos();
10590 let temp_name = format!(
10591 ".dbmd-pull-{}-{nonce}-{}",
10592 std::process::id(),
10593 content_sha256(path.as_bytes())
10594 );
10595 let temp = c_name(temp_name.as_bytes(), path)?;
10596 let fd = unsafe {
10597 libc::openat(
10598 directory.as_raw_fd(),
10599 temp.as_ptr(),
10600 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10601 0o600,
10602 )
10603 };
10604 if fd < 0 {
10605 return Err(std::io::Error::last_os_error().into());
10606 }
10607 let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
10608 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
10609 let mut digest = Sha256::new();
10610 let mut total = 0_u64;
10611 let mut buffer = [0_u8; 64 * 1024];
10612 let copied = (|| -> std::io::Result<()> {
10613 loop {
10614 let read = input.read(&mut buffer)?;
10615 if read == 0 {
10616 break;
10617 }
10618 total = total.saturating_add(read as u64);
10619 if total > entry.bytes {
10620 return Err(std::io::Error::new(
10621 std::io::ErrorKind::InvalidData,
10622 "staged sync source grew beyond its verified length",
10623 ));
10624 }
10625 digest.update(&buffer[..read]);
10626 output.write_all(&buffer[..read])?;
10627 }
10628 Ok(())
10629 })();
10630 if let Err(error) = copied {
10631 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10632 return Err(error.into());
10633 }
10634 drop(output);
10635 if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
10636 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10637 return Err(invalid_feed(
10638 "private staged sync source failed final integrity verification",
10639 ));
10640 }
10641 if unsafe {
10642 libc::renameat(
10643 directory.as_raw_fd(),
10644 temp.as_ptr(),
10645 directory.as_raw_fd(),
10646 leaf_name.as_ptr(),
10647 )
10648 } != 0
10649 {
10650 let error = std::io::Error::last_os_error();
10651 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10652 return Err(error.into());
10653 }
10654 Ok(())
10655}
10656
10657#[cfg(unix)]
10658fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
10659 use std::os::fd::{AsRawFd as _, FromRawFd as _};
10660
10661 let path = &entry.path;
10662 let components: Vec<&str> = path.split('/').collect();
10663 let (leaf, parents) = components
10664 .split_last()
10665 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10666 let mut directory = root.try_clone()?;
10667 for component in parents {
10668 directory = open_dir_at(
10669 directory.as_raw_fd(),
10670 &c_name(component.as_bytes(), path)?,
10671 path,
10672 )?;
10673 }
10674 let leaf = c_name(leaf.as_bytes(), path)?;
10675 let fd = unsafe {
10676 libc::openat(
10677 directory.as_raw_fd(),
10678 leaf.as_ptr(),
10679 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10680 )
10681 };
10682 if fd < 0 {
10683 return Err(std::io::Error::last_os_error().into());
10684 }
10685 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
10686 if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
10687 return Err(invalid_feed(
10688 "private pull stage changed before its durability barrier",
10689 ));
10690 }
10691 file.sync_all()?;
10692 Ok(())
10693}
10694
10695#[cfg(unix)]
10696fn run_pull_source_workers(
10697 root: &std::fs::File,
10698 entries: &[V2StagedFile],
10699 operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
10700) -> LinkResult<()> {
10701 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10702
10703 let next = AtomicUsize::new(0);
10704 let failed = AtomicBool::new(false);
10705 let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
10706 let mut first_error = None;
10707 std::thread::scope(|scope| {
10708 let (sender, receiver) = std::sync::mpsc::channel();
10709 for _ in 0..worker_count {
10710 let sender = sender.clone();
10711 let next = &next;
10712 let failed = &failed;
10713 scope.spawn(move || {
10714 while !failed.load(Ordering::Acquire) {
10715 let index = next.fetch_add(1, Ordering::Relaxed);
10716 let Some(entry) = entries.get(index) else {
10717 break;
10718 };
10719 let result = operation(root, entry);
10720 if result.is_err() {
10721 failed.store(true, Ordering::Release);
10722 }
10723 if sender.send(result).is_err() {
10724 break;
10725 }
10726 }
10727 });
10728 }
10729 drop(sender);
10730 for result in receiver {
10731 if let Err(error) = result {
10732 if first_error.is_none() {
10733 first_error = Some(error);
10734 }
10735 }
10736 }
10737 });
10738 if let Some(error) = first_error {
10739 return Err(error);
10740 }
10741 if next.load(Ordering::Relaxed) < entries.len() {
10742 return Err(invalid_feed(
10743 "a bounded pull worker stopped before reporting every file",
10744 ));
10745 }
10746 Ok(())
10747}
10748
10749#[cfg(unix)]
10750fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
10751 use std::os::fd::AsRawFd as _;
10752
10753 for name in directory_entry_names(root)? {
10754 if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
10755 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
10756 let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
10757 sync_pull_directory_tree(&child, &child_display)?;
10758 }
10759 }
10760 root.sync_all()?;
10761 Ok(())
10762}
10763
10764#[cfg(unix)]
10765fn write_pull_sources_beneath_dir(
10766 root: &std::fs::File,
10767 entries: &[V2StagedFile],
10768) -> LinkResult<()> {
10769 run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
10776 run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
10777 sync_pull_directory_tree(root, "v2 pull stage")
10778}
10779
10780#[cfg(unix)]
10781fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
10782 use std::os::fd::AsRawFd as _;
10783 for path in paths {
10784 if !safe_store_rel_path(path) {
10785 return Err(LinkError::UnsafePath { path: path.clone() });
10786 }
10787 let components = path.split('/').collect::<Vec<_>>();
10788 let Some((leaf, parents)) = components.split_last() else {
10789 return Err(LinkError::UnsafePath { path: path.clone() });
10790 };
10791 let mut directory = root.try_clone()?;
10792 let mut missing = false;
10793 for component in parents {
10794 let name = c_name(component.as_bytes(), path)?;
10795 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
10796 None => {
10797 missing = true;
10798 break;
10799 }
10800 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
10801 Some(true) => {
10802 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10803 }
10804 }
10805 }
10806 if missing {
10807 continue;
10808 }
10809 let leaf = c_name(leaf.as_bytes(), path)?;
10810 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
10811 None => {}
10812 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
10813 Some(false) => {
10814 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
10815 return Err(std::io::Error::last_os_error().into());
10816 }
10817 directory.sync_all()?;
10818 }
10819 }
10820 }
10821 Ok(())
10822}
10823
10824#[cfg(unix)]
10825fn install_pulled_delta(
10826 dest: &Path,
10827 entries: &[(String, Vec<u8>)],
10828 deleted: &[String],
10829 rebuild_indexes: bool,
10830) -> LinkResult<()> {
10831 use ring::rand::SecureRandom as _;
10832 use std::os::fd::AsRawFd as _;
10833 use std::os::unix::ffi::OsStrExt as _;
10834
10835 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10836 let name = dest
10837 .file_name()
10838 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10839 .ok_or_else(|| LinkError::UnsafePath {
10840 path: dest.display().to_string(),
10841 })?;
10842 let parent_dir = open_or_create_dir_nofollow(parent)?;
10843 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10844 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10845 None => false,
10846 Some(true) => true,
10847 Some(false) => {
10848 return Err(LinkError::UnsafePath {
10849 path: dest.display().to_string(),
10850 });
10851 }
10852 };
10853
10854 let mut nonce = [0_u8; 16];
10855 ring::rand::SystemRandom::new()
10856 .fill(&mut nonce)
10857 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10858 let stage_label = format!(
10859 ".{}.dbmd-pull-stage-{}",
10860 name.to_string_lossy(),
10861 URL_SAFE_NO_PAD.encode(nonce)
10862 );
10863 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10864 let stage_dir = create_dir_exclusive_at(
10865 parent_dir.as_raw_fd(),
10866 &stage_name,
10867 &dest.display().to_string(),
10868 )?;
10869
10870 let prepared = (|| -> LinkResult<()> {
10871 if dest_exists {
10872 let live = open_dir_at(
10873 parent_dir.as_raw_fd(),
10874 &dest_name,
10875 &dest.display().to_string(),
10876 )?;
10877 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10878 }
10879 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10880 write_pull_entries_beneath_dir(&stage_dir, entries)?;
10881 if rebuild_indexes {
10882 let stage_store =
10883 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10884 .map_err(|error| LinkError::InvalidPack {
10885 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10886 })?;
10887 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10888 LinkError::InvalidPack {
10889 message: format!("could not materialize v2 local catalogs: {error}"),
10890 }
10891 })?;
10892 }
10893 stage_dir.sync_all()?;
10894 Ok(())
10895 })();
10896 if let Err(error) = prepared {
10897 let _ = remove_tree_at(
10898 parent_dir.as_raw_fd(),
10899 &stage_name,
10900 &dest.display().to_string(),
10901 );
10902 return Err(error);
10903 }
10904
10905 if let Err(error) =
10906 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10907 {
10908 let _ = remove_tree_at(
10909 parent_dir.as_raw_fd(),
10910 &stage_name,
10911 &dest.display().to_string(),
10912 );
10913 return Err(error);
10914 }
10915 parent_dir.sync_all()?;
10916 if dest_exists {
10917 let _ = remove_tree_at(
10921 parent_dir.as_raw_fd(),
10922 &stage_name,
10923 &dest.display().to_string(),
10924 );
10925 let _ = parent_dir.sync_all();
10926 }
10927 Ok(())
10928}
10929
10930#[cfg(unix)]
10931fn install_pulled_delta_sources(
10932 dest: &Path,
10933 entries: &[V2StagedFile],
10934 deleted: &[String],
10935 rebuild_indexes: bool,
10936 _previous: Option<&V2SyncBaseline>,
10937 _next: &V2VerifiedHead,
10938) -> LinkResult<()> {
10939 use ring::rand::SecureRandom as _;
10940 use std::os::fd::AsRawFd as _;
10941 use std::os::unix::ffi::OsStrExt as _;
10942
10943 if let Ok(store) = Store::open_strict(dest) {
10947 return install_established_v2_delta(
10948 store,
10949 entries,
10950 deleted,
10951 rebuild_indexes,
10952 _previous,
10953 _next,
10954 );
10955 }
10956
10957 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10958 let name = dest
10959 .file_name()
10960 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10961 .ok_or_else(|| LinkError::UnsafePath {
10962 path: dest.display().to_string(),
10963 })?;
10964 let parent_dir = open_or_create_dir_nofollow(parent)?;
10965 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10966 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10967 None => false,
10968 Some(true) => true,
10969 Some(false) => {
10970 return Err(LinkError::UnsafePath {
10971 path: dest.display().to_string(),
10972 })
10973 }
10974 };
10975 let mut nonce = [0_u8; 16];
10976 ring::rand::SystemRandom::new()
10977 .fill(&mut nonce)
10978 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10979 let stage_label = format!(
10980 ".{}.dbmd-pull-stage-{}",
10981 name.to_string_lossy(),
10982 URL_SAFE_NO_PAD.encode(nonce)
10983 );
10984 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10985 let stage_dir = create_dir_exclusive_at(
10986 parent_dir.as_raw_fd(),
10987 &stage_name,
10988 &dest.display().to_string(),
10989 )?;
10990 let prepared = (|| -> LinkResult<()> {
10991 if dest_exists {
10992 let live = open_dir_at(
10993 parent_dir.as_raw_fd(),
10994 &dest_name,
10995 &dest.display().to_string(),
10996 )?;
10997 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10998 }
10999 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
11000 write_pull_sources_beneath_dir(&stage_dir, entries)?;
11001 if rebuild_indexes {
11002 let stage_store =
11003 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
11004 .map_err(|error| LinkError::InvalidPack {
11005 message: format!("v2 staging tree is not a valid db.md store: {error}"),
11006 })?;
11007 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
11008 LinkError::InvalidPack {
11009 message: format!("could not materialize v2 local catalogs: {error}"),
11010 }
11011 })?;
11012 }
11013 stage_dir.sync_all()?;
11014 Ok(())
11015 })();
11016 if let Err(error) = prepared {
11017 let _ = remove_tree_at(
11018 parent_dir.as_raw_fd(),
11019 &stage_name,
11020 &dest.display().to_string(),
11021 );
11022 return Err(error);
11023 }
11024 if let Err(error) =
11025 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
11026 {
11027 let _ = remove_tree_at(
11028 parent_dir.as_raw_fd(),
11029 &stage_name,
11030 &dest.display().to_string(),
11031 );
11032 return Err(error);
11033 }
11034 parent_dir.sync_all()?;
11035 if dest_exists {
11036 let _ = remove_tree_at(
11037 parent_dir.as_raw_fd(),
11038 &stage_name,
11039 &dest.display().to_string(),
11040 );
11041 let _ = parent_dir.sync_all();
11042 }
11043 Ok(())
11044}
11045
11046#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
11047struct V2PullCoordinate {
11048 head_seq: Option<u64>,
11049 commit_hash: Option<String>,
11050 view_kind: Option<String>,
11051 view_revision: Option<String>,
11052}
11053
11054#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
11055struct V2PullFileCoordinate {
11056 sha256: String,
11057 bytes: u64,
11058}
11059
11060#[derive(Debug, Clone, Deserialize, Serialize)]
11061struct V2PullJournalEntry {
11062 path: String,
11063 old: Option<V2PullFileCoordinate>,
11064 new: Option<V2PullFileCoordinate>,
11065 backup: Option<String>,
11066}
11067
11068#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
11069#[serde(rename_all = "snake_case")]
11070enum V2PullPhase {
11071 Preparing,
11072 Ready,
11073}
11074
11075#[derive(Debug, Clone, Deserialize, Serialize)]
11076struct V2PullJournal {
11077 v: u8,
11078 phase: V2PullPhase,
11079 brain: String,
11080 previous: V2PullCoordinate,
11081 next: V2PullCoordinate,
11082 backup_dir: String,
11083 entries: Vec<V2PullJournalEntry>,
11084}
11085
11086const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
11087
11088fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
11089 V2PullCoordinate {
11090 head_seq: baseline.and_then(|value| value.head_seq),
11091 commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
11092 view_kind: baseline.and_then(|value| value.view_kind.clone()),
11093 view_revision: baseline.and_then(|value| value.view_revision.clone()),
11094 }
11095}
11096
11097fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
11098 V2PullCoordinate {
11099 head_seq: head.pointer.as_ref().map(|value| value.seq),
11100 commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
11101 view_kind: Some(head.view_kind.clone()),
11102 view_revision: Some(head.view_revision.clone()),
11103 }
11104}
11105
11106fn v2_pull_file_coordinate(
11107 store: &Store,
11108 path: &str,
11109 limit: u64,
11110) -> LinkResult<Option<V2PullFileCoordinate>> {
11111 let file = match store.open_regular(Path::new(path)) {
11112 Ok(file) => file,
11113 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11114 Err(error) => return Err(error.into()),
11115 };
11116 let bytes = file.metadata()?.len();
11117 if bytes > limit || bytes > MAX_STORE_BYTES {
11118 return Err(invalid_feed(
11119 "pull transaction file exceeds its declared bound",
11120 ));
11121 }
11122 Ok(Some(V2PullFileCoordinate {
11123 sha256: content_sha256_reader(file)?,
11124 bytes,
11125 }))
11126}
11127
11128fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
11129 let mut bytes = serde_json::to_vec_pretty(journal)
11130 .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
11131 bytes.push(b'\n');
11132 Ok(bytes)
11133}
11134
11135fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
11136 let backup_prefix = ".dbmd/pull-backup-";
11137 let suffix = journal
11138 .backup_dir
11139 .strip_prefix(backup_prefix)
11140 .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
11141 let mut paths = std::collections::BTreeSet::new();
11142 if journal.v != 1
11143 || !crate::ulid::is_ulid(&journal.brain)
11144 || !crate::ulid::is_ulid(suffix)
11145 || journal.entries.is_empty()
11146 || journal.entries.len() > MAX_PUSH_FILES + 4
11147 || journal.previous == journal.next
11148 {
11149 return Err(invalid_feed("v2 pull journal failed validation"));
11150 }
11151 for (index, entry) in journal.entries.iter().enumerate() {
11152 if !safe_store_rel_path(&entry.path)
11153 || entry.path == V2_PULL_JOURNAL
11154 || entry.path.starts_with(backup_prefix)
11155 || !paths.insert(entry.path.clone())
11156 || (entry.old.is_none() && entry.new.is_none())
11157 || entry
11158 .old
11159 .iter()
11160 .chain(entry.new.iter())
11161 .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
11162 || entry.backup.as_deref()
11163 != entry
11164 .old
11165 .as_ref()
11166 .map(|_| format!("{index:08x}"))
11167 .as_deref()
11168 {
11169 return Err(invalid_feed("v2 pull journal entry failed validation"));
11170 }
11171 }
11172 Ok(())
11173}
11174
11175fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
11176 #[cfg(unix)]
11177 {
11178 use std::os::unix::fs::PermissionsExt as _;
11179 match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
11180 Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
11181 return Err(invalid_feed(
11182 "v2 pull journal is accessible to group/other; set mode 0600",
11183 ));
11184 }
11185 Ok(_) => {}
11186 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11187 Err(error) => return Err(error.into()),
11188 }
11189 }
11190 let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
11191 Ok(bytes) => bytes,
11192 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11193 Err(error) => return Err(error.into()),
11194 };
11195 let journal: V2PullJournal =
11196 serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
11197 validate_v2_pull_journal(&journal)?;
11198 Ok(Some(journal))
11199}
11200
11201fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
11202 match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
11206 Ok(()) => {}
11207 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
11208 Err(error) => return Err(error.into()),
11209 }
11210 match store.remove_private_tree(Path::new(&journal.backup_dir)) {
11211 Ok(()) => Ok(()),
11212 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
11213 Err(error) => Err(error.into()),
11214 }
11215}
11216
11217fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
11218 let names = match store.directory_names(Path::new(".dbmd")) {
11219 Ok(names) => names,
11220 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
11221 Err(error) => return Err(error.into()),
11222 };
11223 for name in names {
11224 let Some(name) = name.to_str() else {
11225 continue;
11226 };
11227 let Some(suffix) = name.strip_prefix("pull-backup-") else {
11228 continue;
11229 };
11230 if crate::ulid::is_ulid(suffix) {
11231 store.remove_private_tree(&Path::new(".dbmd").join(name))?;
11232 }
11233 }
11234 Ok(())
11235}
11236
11237fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
11238 for entry in &journal.entries {
11240 let limit = entry
11241 .old
11242 .as_ref()
11243 .into_iter()
11244 .chain(entry.new.iter())
11245 .map(|value| value.bytes)
11246 .max()
11247 .unwrap_or(0);
11248 let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
11249 if current != entry.old && current != entry.new {
11250 return Err(LinkError::InvalidPack {
11251 message: format!(
11252 "cannot recover interrupted pull because `{}` changed afterward",
11253 entry.path
11254 ),
11255 });
11256 }
11257 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
11258 let path = Path::new(&journal.backup_dir).join(backup);
11259 let file = store.open_regular(&path)?;
11260 if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
11261 return Err(invalid_feed("v2 pull recovery backup failed verification"));
11262 }
11263 }
11264 }
11265 for entry in journal.entries.iter().rev() {
11266 match (&entry.old, &entry.backup) {
11267 (Some(old), Some(backup)) => {
11268 let bytes =
11269 store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
11270 store.write_atomic(Path::new(&entry.path), &bytes)?;
11271 }
11272 (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
11273 store.remove_file(Path::new(&entry.path))?;
11274 }
11275 (None, None) => {}
11276 _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
11277 }
11278 }
11279 crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
11280 message: format!("could not rebuild catalogs after pull recovery: {error}"),
11281 })?;
11282 cleanup_v2_pull_journal(store, journal)
11283}
11284
11285fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
11286 let Ok(store) = Store::open_strict(dest) else {
11287 return Ok(());
11288 };
11289 if let Some(journal) = load_v2_pull_journal(&store)? {
11290 if journal.brain != brain {
11291 return Err(invalid_feed("v2 pull journal belongs to another brain"));
11292 }
11293 if journal.phase == V2PullPhase::Preparing {
11294 cleanup_v2_pull_journal(&store, &journal)?;
11295 } else {
11296 let baseline = load_v2_baseline(cfg, brain, dest)?;
11297 let current = v2_pull_baseline_coordinate(baseline.as_ref());
11298 if current == journal.next {
11299 cleanup_v2_pull_journal(&store, &journal)?;
11300 } else {
11301 if current != journal.previous {
11302 return Err(invalid_feed(
11303 "cannot recover interrupted pull because its baseline changed afterward",
11304 ));
11305 }
11306 rollback_v2_pull(&store, &journal)?;
11307 }
11308 }
11309 }
11310 prune_orphan_v2_pull_backups(&store)
11315}
11316
11317fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
11318 let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
11319 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
11320 })?;
11321 if let Some(journal) = load_v2_pull_journal(&store)? {
11322 cleanup_v2_pull_journal(&store, &journal)?;
11323 }
11324 Ok(())
11325}
11326
11327#[cfg(windows)]
11328fn install_windows_initial_sources(
11329 dest: &Path,
11330 entries: &[V2StagedFile],
11331 rebuild_indexes: bool,
11332) -> LinkResult<()> {
11333 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
11334 let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
11335 path: dest.display().to_string(),
11336 })?;
11337 let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
11338 if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
11339 return Err(LinkError::UnsafePath {
11340 path: dest.display().to_string(),
11341 });
11342 }
11343 let stage_name = format!(
11344 ".{}.dbmd-pull-stage-{}",
11345 name.to_string_lossy(),
11346 crate::ulid::mint()
11347 );
11348 let stage_path = parent.join(&stage_name);
11349 let stage_capability =
11350 crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
11351 let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
11352 let prepared = (|| -> LinkResult<()> {
11353 for entry in entries {
11354 let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
11355 if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
11356 return Err(invalid_feed(
11357 "private staged sync source failed final integrity verification",
11358 ));
11359 }
11360 stage.write_atomic(Path::new(&entry.path), &bytes)?;
11361 }
11362 let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
11363 .map_err(|error| LinkError::InvalidPack {
11364 message: format!("v2 staging tree is not a valid db.md store: {error}"),
11365 })?;
11366 if rebuild_indexes {
11367 crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
11368 message: format!("could not materialize v2 local catalogs: {error}"),
11369 })?;
11370 }
11371 Ok(())
11372 })();
11373 if let Err(error) = prepared {
11374 let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
11375 return Err(error);
11376 }
11377 crate::fsx::rename_directory_beneath(
11378 &parent_capability,
11379 Path::new(&stage_name),
11380 Path::new(name),
11381 )?;
11382 Ok(())
11383}
11384
11385fn install_established_v2_delta(
11386 store: Store,
11387 entries: &[V2StagedFile],
11388 deleted: &[String],
11389 rebuild_indexes: bool,
11390 previous: Option<&V2SyncBaseline>,
11391 next: &V2VerifiedHead,
11392) -> LinkResult<()> {
11393 if load_v2_pull_journal(&store)?.is_some() {
11394 return Err(invalid_feed(
11395 "an interrupted pull must be recovered before installing",
11396 ));
11397 }
11398 let mut sources = std::collections::BTreeMap::new();
11399 for entry in entries {
11400 if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
11401 return Err(invalid_feed("pull mutation repeats a path"));
11402 }
11403 }
11404 let mut paths = sources.keys().cloned().collect::<Vec<_>>();
11405 paths.extend(deleted.iter().cloned());
11406 paths.sort();
11407 paths.dedup();
11408 if paths.is_empty() {
11409 return Ok(());
11410 }
11411 let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
11412 let mut journal = V2PullJournal {
11413 v: 1,
11414 phase: V2PullPhase::Preparing,
11415 brain: next.brain_id.clone(),
11416 previous: v2_pull_baseline_coordinate(previous),
11417 next: v2_pull_head_coordinate(next),
11418 backup_dir: backup_dir.clone(),
11419 entries: Vec::with_capacity(paths.len()),
11420 };
11421 for path in &paths {
11422 let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
11423 let new = sources.get(path).map(|entry| V2PullFileCoordinate {
11424 sha256: entry.sha256.clone(),
11425 bytes: entry.bytes,
11426 });
11427 if old == new {
11428 continue;
11429 }
11430 let index = journal.entries.len();
11431 journal.entries.push(V2PullJournalEntry {
11432 path: path.clone(),
11433 backup: old.as_ref().map(|_| format!("{index:08x}")),
11434 old,
11435 new,
11436 });
11437 }
11438 if journal.entries.is_empty() {
11439 return Ok(());
11440 }
11441 let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
11442 entry
11443 .old
11444 .as_ref()
11445 .map_or(Some(total), |old| total.checked_add(old.bytes))
11446 });
11447 if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
11448 return Err(LinkError::InvalidPack {
11449 message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
11450 });
11451 }
11452 validate_v2_pull_journal(&journal)?;
11453 store.write_private_atomic_new(
11454 Path::new(V2_PULL_JOURNAL),
11455 &v2_pull_journal_bytes(&journal)?,
11456 )?;
11457 let prepared = (|| -> LinkResult<()> {
11458 store.create_private_dir_all(Path::new(&backup_dir))?;
11459 for entry in &journal.entries {
11460 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
11461 let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
11462 if content_sha256(&bytes) != old.sha256 {
11463 return Err(invalid_feed("live pull source changed during backup"));
11464 }
11465 store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
11466 }
11467 }
11468 journal.phase = V2PullPhase::Ready;
11469 store.write_private_atomic(
11470 Path::new(V2_PULL_JOURNAL),
11471 &v2_pull_journal_bytes(&journal)?,
11472 )?;
11473 Ok(())
11474 })();
11475 if let Err(error) = prepared {
11476 let cleanup = cleanup_v2_pull_journal(&store, &journal);
11477 return match cleanup {
11478 Ok(()) => Err(error),
11479 Err(cleanup) => Err(LinkError::InvalidPack {
11480 message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
11481 }),
11482 };
11483 }
11484 let installed = (|| -> LinkResult<()> {
11485 for entry in &journal.entries {
11486 if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
11487 return Err(LinkError::InvalidPack {
11488 message: format!("local path `{}` changed during pull", entry.path),
11489 });
11490 }
11491 if let Some(source) = sources.get(&entry.path) {
11492 let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
11493 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
11494 return Err(invalid_feed(
11495 "private staged sync source failed final integrity verification",
11496 ));
11497 }
11498 store.write_atomic(Path::new(&entry.path), &bytes)?;
11499 } else if entry.old.is_some() {
11500 store.remove_file(Path::new(&entry.path))?;
11501 }
11502 }
11503 if rebuild_indexes {
11504 crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
11505 message: format!("could not materialize v2 local catalogs: {error}"),
11506 })?;
11507 }
11508 Ok(())
11509 })();
11510 if let Err(error) = installed {
11511 return match rollback_v2_pull(&store, &journal) {
11512 Ok(()) => Err(error),
11513 Err(rollback) => Err(LinkError::InvalidPack {
11514 message: format!("{error}; durable pull rollback also failed: {rollback}"),
11515 }),
11516 };
11517 }
11518 Ok(())
11519}
11520
11521#[cfg(windows)]
11522fn install_pulled_delta_sources(
11523 dest: &Path,
11524 entries: &[V2StagedFile],
11525 deleted: &[String],
11526 rebuild_indexes: bool,
11527 previous: Option<&V2SyncBaseline>,
11528 next: &V2VerifiedHead,
11529) -> LinkResult<()> {
11530 match Store::open_strict(dest) {
11531 Ok(store) => {
11532 install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
11533 }
11534 Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
11535 }
11536}
11537
11538#[cfg(not(any(unix, windows)))]
11539fn install_pulled_delta_sources(
11540 _dest: &Path,
11541 _entries: &[V2StagedFile],
11542 _deleted: &[String],
11543 _rebuild_indexes: bool,
11544 _previous: Option<&V2SyncBaseline>,
11545 _next: &V2VerifiedHead,
11546) -> LinkResult<()> {
11547 Err(LinkError::UnsupportedPlatform {
11548 operation: "atomic v2 pull install",
11549 })
11550}
11551
11552#[cfg(unix)]
11553fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
11554 install_pulled_delta(dest, entries, &[], false)
11555}
11556
11557#[cfg(not(windows))]
11558fn is_safe_slug(slug: &str) -> bool {
11559 !slug.is_empty()
11560 && slug.len() <= 63
11561 && !slug.starts_with('-')
11562 && !slug.ends_with('-')
11563 && slug
11564 .bytes()
11565 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
11566}
11567
11568fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
11569 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
11570}
11571
11572fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
11573 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
11574}
11575
11576fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
11577 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
11578}
11579
11580fn preflight_zip_central_directory(
11581 bytes: &[u8],
11582 offset: usize,
11583 size: usize,
11584 count: u64,
11585) -> LinkResult<()> {
11586 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
11587 let end = offset
11588 .checked_add(size)
11589 .filter(|end| *end <= bytes.len())
11590 .ok_or_else(|| LinkError::InvalidPack {
11591 message: "ZIP central directory is out of bounds".to_string(),
11592 })?;
11593 let mut cursor = offset;
11594 for _ in 0..count {
11595 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
11596 return Err(LinkError::InvalidPack {
11597 message: "ZIP central directory entry count is inconsistent".to_string(),
11598 });
11599 }
11600 if le_u16(bytes, cursor + 34) != Some(0) {
11601 return Err(LinkError::InvalidPack {
11602 message: "multi-disk ZIP archives are not supported".to_string(),
11603 });
11604 }
11605 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
11606 total.checked_add(le_u16(bytes, cursor + at)? as usize)
11607 });
11608 cursor = cursor
11609 .checked_add(46)
11610 .and_then(|fixed| fixed.checked_add(variable?))
11611 .filter(|cursor| *cursor <= end)
11612 .ok_or_else(|| LinkError::InvalidPack {
11613 message: "ZIP central directory entry is truncated".to_string(),
11614 })?;
11615 }
11616 if cursor != end {
11617 return Err(LinkError::InvalidPack {
11618 message: "ZIP central directory size is inconsistent".to_string(),
11619 });
11620 }
11621 Ok(())
11622}
11623
11624fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
11628 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
11629 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
11630 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
11631 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
11632 let eocd = bytes[search_start..]
11633 .windows(4)
11634 .rposition(|window| window == EOCD_SIG)
11635 .map(|offset| search_start + offset)
11636 .ok_or_else(|| LinkError::InvalidPack {
11637 message: "ZIP has no end-of-central-directory record".to_string(),
11638 })?;
11639 let invalid_end = || LinkError::InvalidPack {
11640 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
11641 };
11642 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
11643 if eocd
11644 .checked_add(22)
11645 .and_then(|end| end.checked_add(comment_len))
11646 != Some(bytes.len())
11647 {
11648 return Err(invalid_end());
11652 }
11653 let disk = le_u16(bytes, eocd + 4);
11654 let central_disk = le_u16(bytes, eocd + 6);
11655 if disk != Some(0) || central_disk != Some(0) {
11656 return Err(LinkError::InvalidPack {
11657 message: "multi-disk ZIP archives are not supported".to_string(),
11658 });
11659 }
11660 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
11661 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
11662 if entries_on_disk != ordinary {
11663 return Err(LinkError::InvalidPack {
11664 message: "multi-disk ZIP archives are not supported".to_string(),
11665 });
11666 }
11667 let zip64_locator = eocd
11668 .checked_sub(20)
11669 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
11670 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
11671 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
11672 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
11673 if central_offset
11674 .checked_add(central_size)
11675 .filter(|end| *end == eocd)
11676 .is_none()
11677 {
11678 return Err(invalid_end());
11679 }
11680 (ordinary as u64, central_offset, central_size)
11681 } else {
11682 let Some(locator) = zip64_locator else {
11683 return Err(invalid_end());
11684 };
11685 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
11686 return Err(LinkError::InvalidPack {
11687 message: "multi-disk ZIP64 archives are not supported".to_string(),
11688 });
11689 }
11690 let record = le_u64(bytes, locator + 8)
11691 .and_then(|offset| usize::try_from(offset).ok())
11692 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
11693 .ok_or_else(|| LinkError::InvalidPack {
11694 message: "ZIP64 archive has an invalid end record".to_string(),
11695 })?;
11696 let record_size = le_u64(bytes, record + 4)
11697 .and_then(|size| usize::try_from(size).ok())
11698 .filter(|size| *size >= 44)
11699 .ok_or_else(invalid_end)?;
11700 if record
11701 .checked_add(12)
11702 .and_then(|end| end.checked_add(record_size))
11703 != Some(locator)
11704 || le_u32(bytes, record + 16) != Some(0)
11705 || le_u32(bytes, record + 20) != Some(0)
11706 {
11707 return Err(invalid_end());
11708 }
11709 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
11710 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
11711 let central_size = le_u64(bytes, record + 40)
11712 .and_then(|size| usize::try_from(size).ok())
11713 .ok_or_else(invalid_end)?;
11714 let central_offset = le_u64(bytes, record + 48)
11715 .and_then(|offset| usize::try_from(offset).ok())
11716 .ok_or_else(invalid_end)?;
11717 if zip64_on_disk != zip64_total
11718 || central_offset
11719 .checked_add(central_size)
11720 .filter(|end| *end == record)
11721 .is_none()
11722 {
11723 return Err(invalid_end());
11724 }
11725 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
11726 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
11727 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
11728 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
11729 {
11730 return Err(invalid_end());
11731 }
11732 (zip64_total, central_offset, central_size)
11733 };
11734 if count == 0 || count > max_entries as u64 {
11735 return Err(LinkError::InvalidPack {
11736 message: format!("invalid file count {count}"),
11737 });
11738 }
11739 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
11740 Ok(())
11741}
11742
11743fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
11744 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
11745 let mut archive =
11746 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
11747 message: format!("ZIP parse failed: {err}"),
11748 })?;
11749 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
11750 return Err(LinkError::InvalidPack {
11751 message: format!("invalid file count {}", archive.len()),
11752 });
11753 }
11754 let mut total = 0u64;
11755 let mut seen = std::collections::HashSet::new();
11756 let mut entries = Vec::with_capacity(archive.len());
11757 for index in 0..archive.len() {
11758 let mut file = archive
11759 .by_index(index)
11760 .map_err(|err| LinkError::InvalidPack {
11761 message: format!("ZIP entry failed: {err}"),
11762 })?;
11763 if file.is_dir() {
11764 continue;
11765 }
11766 let path = file.name().to_string();
11767 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
11768 return Err(LinkError::UnsafePath { path });
11769 }
11770 if file
11771 .unix_mode()
11772 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
11773 {
11774 return Err(LinkError::InvalidPack {
11775 message: format!("non-file entry `{path}`"),
11776 });
11777 }
11778 if !seen.insert(path.clone()) {
11779 return Err(LinkError::InvalidPack {
11780 message: format!("duplicate path `{path}`"),
11781 });
11782 }
11783 let remaining = MAX_STORE_BYTES.saturating_sub(total);
11784 if file.size() > remaining {
11785 return Err(LinkError::InvalidPack {
11786 message: "expanded content exceeds the 512 MB limit".to_string(),
11787 });
11788 }
11789 let mut content = Vec::new();
11790 (&mut file)
11791 .take(remaining + 1)
11792 .read_to_end(&mut content)
11793 .map_err(|err| LinkError::InvalidPack {
11794 message: format!("could not decompress `{path}`: {err}"),
11795 })?;
11796 if content.len() as u64 > remaining {
11797 return Err(LinkError::InvalidPack {
11798 message: "expanded content exceeds the 512 MB limit".to_string(),
11799 });
11800 }
11801 if content.len() as u64 != file.size() {
11802 return Err(LinkError::InvalidPack {
11803 message: format!("length mismatch for `{path}`"),
11804 });
11805 }
11806 total += content.len() as u64;
11807 entries.push((path, content));
11808 }
11809 if entries.is_empty() {
11810 return Err(LinkError::InvalidPack {
11811 message: "pack contains no files".to_string(),
11812 });
11813 }
11814 Ok(entries)
11815}
11816
11817fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
11818 let mut expected = std::collections::BTreeMap::new();
11819 for file in signed {
11820 if !safe_store_rel_path(&file.path) {
11821 return Err(LinkError::UnsafePath {
11822 path: file.path.clone(),
11823 });
11824 }
11825 if !is_sha256(&file.sha256)
11826 || expected
11827 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11828 .is_some()
11829 {
11830 return Err(invalid_feed(
11831 "signed snapshot manifest contains an invalid or duplicate file",
11832 ));
11833 }
11834 }
11835 if expected.len() != entries.len() {
11836 return Err(invalid_feed(
11837 "downloaded pack file set differs from the signed snapshot manifest",
11838 ));
11839 }
11840 for (path, bytes) in entries {
11841 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
11842 return Err(invalid_feed(format!(
11843 "downloaded pack contains unsigned path `{path}`"
11844 )));
11845 };
11846 if *declared_bytes != bytes.len() as u64
11847 || *sha256 != format!("{:x}", Sha256::digest(bytes))
11848 {
11849 return Err(invalid_feed(format!(
11850 "downloaded file `{path}` differs from its signed manifest"
11851 )));
11852 }
11853 }
11854 Ok(())
11855}
11856
11857pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
11863 require_hardened_filesystem("sync push")?;
11864 preflight_push_ownership(store)?;
11865 let mut out: Vec<(String, String)> = Vec::new();
11866 let mut total = 0u64;
11867
11868 let mut read_text = |rel: &str| -> LinkResult<String> {
11869 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
11870 total = total
11871 .checked_add(bytes.len() as u64)
11872 .ok_or_else(|| LinkError::PushTooLarge {
11873 detail: "uncompressed byte count overflow".to_string(),
11874 })?;
11875 if total > MAX_STORE_BYTES {
11876 return Err(LinkError::PushTooLarge {
11877 detail: format!("{total} uncompressed bytes"),
11878 });
11879 }
11880 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
11881 path: rel.to_string(),
11882 })
11883 };
11884
11885 out.push(("DB.md".to_string(), read_text("DB.md")?));
11886 if store
11887 .regular_file_exists(Path::new("assets.jsonl"))
11888 .unwrap_or(false)
11889 {
11890 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
11891 }
11892 if store
11893 .regular_file_exists(Path::new("log.md"))
11894 .unwrap_or(false)
11895 {
11896 out.push(("log.md".to_string(), read_text("log.md")?));
11897 }
11898 if store.directory_exists(Path::new("log"))? {
11899 for rel in store.walk_regular_files(Path::new("log"))? {
11900 let rel_str = rel.to_string_lossy().replace('\\', "/");
11901 if rel.extension().and_then(std::ffi::OsStr::to_str) != Some("md") {
11902 continue;
11903 }
11904 if !safe_store_rel_path(&rel_str) {
11905 return Err(LinkError::UnsafePath { path: rel_str });
11906 }
11907 let content = read_text(&rel_str)?;
11908 out.push((rel_str, content));
11909 }
11910 }
11911
11912 for rel in store.walk()? {
11913 let rel_str = rel.to_string_lossy().replace('\\', "/");
11914 if !safe_store_rel_path(&rel_str) {
11915 return Err(LinkError::UnsafePath { path: rel_str });
11918 }
11919 let content = read_text(&rel_str)?;
11920 out.push((rel_str, content));
11921 }
11922
11923 out.sort_by(|a, b| a.0.cmp(&b.0));
11924 Ok(out)
11925}
11926
11927fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
11931 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
11932 return Err(LinkError::from(std::io::Error::new(
11933 std::io::ErrorKind::PermissionDenied,
11934 format!("cannot push: nested db.md store at {}", nested.display()),
11935 )));
11936 }
11937
11938 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
11939 return Err(LinkError::from(std::io::Error::new(
11940 std::io::ErrorKind::PermissionDenied,
11941 format!(
11942 "cannot push: {} is a symlink outside the store ownership model",
11943 symlink.display()
11944 ),
11945 )));
11946 }
11947 Ok(())
11948}
11949
11950pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
11956 require_safe_ref(brain)?;
11957 let remote = verified_remote_head(cfg, brain, false)?;
11958 if files.len() > MAX_PUSH_FILES {
11959 return Err(LinkError::PushTooLarge {
11960 detail: format!("{} files", files.len()),
11961 });
11962 }
11963 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
11964 if raw_total > MAX_STORE_BYTES {
11965 return Err(LinkError::PushTooLarge {
11966 detail: format!("{raw_total} uncompressed bytes"),
11967 });
11968 }
11969
11970 if cfg.brain_key.is_none() {
11974 let body = json!({
11975 "files": files
11976 .iter()
11977 .map(|(p, c)| json!({ "path": p, "content": c }))
11978 .collect::<Vec<_>>(),
11979 });
11980 if body.to_string().len() <= MAX_PUSH_BYTES {
11981 let path = format!("/api/hub/brains/{brain}/push");
11982 let pushed = ensure_ok(
11983 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11984 "sync push",
11985 )?;
11986 return Ok(pushed);
11987 }
11988 }
11989
11990 let pack = build_store_pack(files)?;
11991 if pack.len() as u64 > MAX_PACK_BYTES {
11992 return Err(LinkError::PushTooLarge {
11993 detail: format!("{} pack bytes", pack.len()),
11994 });
11995 }
11996 let sha256 = format!("{:x}", Sha256::digest(&pack));
11997 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
11998 if let Some(key) = &cfg.brain_key {
11999 if !remote.head.verified {
12000 return Err(invalid_feed(
12001 "self-custody push requires a fully verified, unscoped feed head",
12002 ));
12003 }
12004 let identity = remote
12005 .identity
12006 .as_ref()
12007 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
12008 let current_multikey = format!("ed25519:{}", identity.fingerprint);
12009 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
12010 return Err(invalid_feed(
12011 "configured brain key is not the verified current brain identity",
12012 ));
12013 }
12014 let next_seq = remote
12017 .head
12018 .seq
12019 .checked_add(1)
12020 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
12021 let mut manifest: Vec<WireFeedFile> = files
12022 .iter()
12023 .map(|(path, content)| WireFeedFile {
12024 path: path.clone(),
12025 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
12026 bytes: content.len() as u64,
12027 })
12028 .collect();
12029 manifest.sort_by(|a, b| a.path.cmp(&b.path));
12030 let ts = crate::now()
12031 .with_timezone(&chrono::Utc)
12032 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
12033 .to_string();
12034 let entry = self_custody_entry(
12035 key,
12036 next_seq,
12037 ts,
12038 &sha256,
12039 &manifest,
12040 remote.head.feed_hash.as_deref(),
12041 )?;
12042 meta["entry"] = Value::String(entry);
12043 }
12044 let presigned = ensure_ok(
12045 request(
12046 cfg,
12047 "POST",
12048 &format!("/api/hub/brains/{brain}/packs/presign"),
12049 Some(&meta),
12050 Auth::Required,
12051 )?,
12052 "prepare pack upload",
12053 )?;
12054 let url = presigned
12055 .get("url")
12056 .and_then(Value::as_str)
12057 .ok_or_else(|| LinkError::InvalidPack {
12058 message: "the hub returned no upload URL".to_string(),
12059 })?;
12060 put_presigned(
12061 cfg,
12062 url,
12063 presigned.get("headers").unwrap_or(&Value::Null),
12064 &pack,
12065 )?;
12066 let committed = ensure_ok(
12067 request(
12068 cfg,
12069 "POST",
12070 &format!("/api/hub/brains/{brain}/packs/commit"),
12071 Some(&meta),
12072 Auth::Required,
12073 )?,
12074 "commit pack",
12075 )?;
12076 Ok(committed)
12077}
12078
12079fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
12080 const LOCAL_HEADER: u32 = 0x0403_4b50;
12081 const CENTRAL_HEADER: u32 = 0x0201_4b50;
12082 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
12083 const VERSION_20: u16 = 20;
12084 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
12085 const UTF8_FLAG: u16 = 1 << 11;
12086 const STORED: u16 = 0;
12087 const DOS_TIME_MIDNIGHT: u16 = 0;
12088 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
12089 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
12090
12091 struct CentralEntry<'a> {
12092 name: &'a [u8],
12093 crc32: u32,
12094 size: u32,
12095 local_offset: u32,
12096 }
12097
12098 fn push_u16(out: &mut Vec<u8>, value: u16) {
12099 out.extend_from_slice(&value.to_le_bytes());
12100 }
12101
12102 fn push_u32(out: &mut Vec<u8>, value: u32) {
12103 out.extend_from_slice(&value.to_le_bytes());
12104 }
12105
12106 if files.is_empty() {
12107 return Err(LinkError::InvalidPack {
12108 message: "cannot create an empty snapshot pack".to_string(),
12109 });
12110 }
12111 if files.len() > u16::MAX as usize {
12112 return Err(LinkError::PushTooLarge {
12113 detail: format!(
12114 "{} files (canonical ZIP32 packs cap at {})",
12115 files.len(),
12116 u16::MAX
12117 ),
12118 });
12119 }
12120
12121 let mut sorted: Vec<_> = files.iter().collect();
12122 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
12123 let mut previous: Option<&str> = None;
12124 for (path, content) in &sorted {
12125 if !safe_store_rel_path(path) {
12126 return Err(LinkError::UnsafePath {
12127 path: (*path).clone(),
12128 });
12129 }
12130 if previous == Some(path.as_str()) {
12131 return Err(LinkError::InvalidPack {
12132 message: format!("duplicate path `{path}`"),
12133 });
12134 }
12135 previous = Some(path.as_str());
12136 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
12137 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
12138 })?;
12139 }
12140
12141 let mut out = Vec::new();
12142 let mut central = Vec::with_capacity(sorted.len());
12143 for (path, content) in sorted {
12144 let name = path.as_bytes();
12145 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
12146 message: format!("ZIP entry name is too long: `{path}`"),
12147 })?;
12148 let bytes = content.as_bytes();
12149 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
12150 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
12151 })?;
12152 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
12153 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
12154 })?;
12155 let crc32 = crc32fast::hash(bytes);
12156
12157 push_u32(&mut out, LOCAL_HEADER);
12160 push_u16(&mut out, VERSION_20);
12161 push_u16(&mut out, UTF8_FLAG);
12162 push_u16(&mut out, STORED);
12163 push_u16(&mut out, DOS_TIME_MIDNIGHT);
12164 push_u16(&mut out, DOS_DATE_1980_01_01);
12165 push_u32(&mut out, crc32);
12166 push_u32(&mut out, size);
12167 push_u32(&mut out, size);
12168 push_u16(&mut out, name_len);
12169 push_u16(&mut out, 0); out.extend_from_slice(name);
12171 out.extend_from_slice(bytes);
12172
12173 central.push(CentralEntry {
12174 name,
12175 crc32,
12176 size,
12177 local_offset,
12178 });
12179 }
12180
12181 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
12182 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
12183 })?;
12184 for entry in ¢ral {
12185 push_u32(&mut out, CENTRAL_HEADER);
12186 push_u16(&mut out, MADE_BY_UNIX_20);
12187 push_u16(&mut out, VERSION_20);
12188 push_u16(&mut out, UTF8_FLAG);
12189 push_u16(&mut out, STORED);
12190 push_u16(&mut out, DOS_TIME_MIDNIGHT);
12191 push_u16(&mut out, DOS_DATE_1980_01_01);
12192 push_u32(&mut out, entry.crc32);
12193 push_u32(&mut out, entry.size);
12194 push_u32(&mut out, entry.size);
12195 push_u16(&mut out, entry.name.len() as u16);
12196 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);
12201 push_u32(&mut out, entry.local_offset);
12202 out.extend_from_slice(entry.name);
12203 }
12204 let central_size = u32::try_from(out.len())
12205 .ok()
12206 .and_then(|end| end.checked_sub(central_offset))
12207 .ok_or_else(|| LinkError::PushTooLarge {
12208 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
12209 })?;
12210 let entry_count = central.len() as u16;
12211
12212 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
12213 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
12216 push_u16(&mut out, entry_count);
12217 push_u32(&mut out, central_size);
12218 push_u32(&mut out, central_offset);
12219 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
12222 return Err(LinkError::PushTooLarge {
12223 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
12224 });
12225 }
12226 Ok(out)
12227}
12228
12229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12235pub enum Capability {
12236 Read,
12238 Write,
12240}
12241
12242impl Capability {
12243 pub fn as_str(self) -> &'static str {
12245 match self {
12246 Capability::Read => "read",
12247 Capability::Write => "write",
12248 }
12249 }
12250}
12251
12252pub fn grant_issue(
12258 cfg: &HubConfig,
12259 brain: &str,
12260 grantee: &str,
12261 can: Capability,
12262 scope: Option<&str>,
12263 until: Option<&str>,
12264) -> LinkResult<Value> {
12265 require_safe_ref(brain)?;
12266 let is_key_grantee = URL_SAFE_NO_PAD
12271 .decode(grantee)
12272 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
12273 .unwrap_or(false);
12274 if let Some(head) = v2_verified_head(cfg, brain)? {
12275 if is_key_grantee {
12276 let scope = scope.unwrap_or("");
12277 let preset = match can {
12278 Capability::Read => "viewer",
12279 Capability::Write => "editor",
12280 };
12281 let entropy = format!(
12282 "{}\0{}\0{}\0{}\0{}\0{}\0{}",
12283 normalized_origin(&cfg.hub)?,
12284 head.brain_id,
12285 head.control_revision,
12286 grantee,
12287 preset,
12288 scope,
12289 until.unwrap_or("")
12290 );
12291 let mut body = json!({
12292 "context": "external",
12293 "expected_control_revision": head.control_revision,
12294 "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
12295 "preset": preset,
12296 "principal_kind": "key",
12297 "public_key": grantee,
12298 "scope": scope,
12299 "scope_kind": "prefix",
12300 });
12301 if let Some(value) = until {
12302 body["expires_at"] = json!(value);
12303 }
12304 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
12305 let response = ensure_ok(
12306 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12307 "v2 grant issue",
12308 )?;
12309 let expected_fingerprint = identity_fingerprint(grantee)?;
12310 if response.get("v").and_then(Value::as_u64) != Some(2)
12311 || response
12312 .get("id")
12313 .and_then(Value::as_str)
12314 .is_none_or(|id| !crate::ulid::is_ulid(id))
12315 || response.get("principal_kind").and_then(Value::as_str) != Some("key")
12316 || response.get("principal_id").and_then(Value::as_str)
12317 != Some(expected_fingerprint.as_str())
12318 || response
12319 .get("control_revision")
12320 .and_then(Value::as_str)
12321 .is_none_or(|value| !is_sha256(value))
12322 {
12323 return Err(invalid_feed(
12324 "v2 grant issue response is not authority-bound",
12325 ));
12326 }
12327 return Ok(response);
12328 }
12329 let mut body = json!({ "email": grantee, "capability": can.as_str() });
12335 if let Some(value) = scope {
12336 body["scopePrefix"] = json!(value);
12337 }
12338 if let Some(value) = until {
12339 body["expiresAt"] = json!(value);
12340 }
12341 let path = format!("/api/hub/brains/{}/grants", head.brain_id);
12342 return ensure_ok(
12343 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12344 "account grant issue",
12345 );
12346 }
12347 let _ = verified_remote_head(cfg, brain, false)?;
12348 let mut body = if is_key_grantee {
12349 json!({ "keySpki": grantee, "capability": can.as_str() })
12350 } else {
12351 json!({ "email": grantee, "capability": can.as_str() })
12352 };
12353 if let Some(s) = scope {
12354 body["scopePrefix"] = json!(s);
12355 }
12356 if let Some(u) = until {
12357 body["expiresAt"] = json!(u);
12358 }
12359 let path = format!("/api/hub/brains/{brain}/grants");
12360 ensure_ok(
12361 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12362 "grant issue",
12363 )
12364}
12365
12366pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
12368 require_safe_ref(brain)?;
12369 if let Some(head) = v2_verified_head(cfg, brain)? {
12370 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
12371 let response = ensure_ok(
12372 request(cfg, "GET", &path, None, Auth::Required)?,
12373 "v2 grant list",
12374 )?;
12375 if response.get("v").and_then(Value::as_u64) != Some(2)
12376 || response.get("control_revision").and_then(Value::as_str)
12377 != Some(head.control_revision.as_str())
12378 || !response.get("grants").is_some_and(Value::is_array)
12379 {
12380 return Err(invalid_feed(
12381 "v2 grant list is not bound to the verified authority",
12382 ));
12383 }
12384 return Ok(response);
12385 }
12386 let _ = verified_remote_head(cfg, brain, false)?;
12387 let path = format!("/api/hub/brains/{brain}/grants");
12388 ensure_ok(
12389 request(cfg, "GET", &path, None, Auth::Required)?,
12390 "grant list",
12391 )
12392}
12393
12394pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
12397 require_safe_ref(brain)?;
12398 require_safe_grant_id(grant_id)?;
12399 if let Some(head) = v2_verified_head(cfg, brain)? {
12400 let entropy = format!(
12401 "{}\0{}\0{}\0{}",
12402 normalized_origin(&cfg.hub)?,
12403 head.brain_id,
12404 head.control_revision,
12405 grant_id
12406 );
12407 let body = json!({
12408 "expected_control_revision": head.control_revision,
12409 "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
12410 });
12411 let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
12412 let response = ensure_ok(
12413 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
12414 "v2 grant revoke",
12415 )?;
12416 if response.get("v").and_then(Value::as_u64) != Some(2)
12417 || response.get("id").and_then(Value::as_str) != Some(grant_id)
12418 || response.get("revoked").and_then(Value::as_bool) != Some(true)
12419 || response
12420 .get("control_revision")
12421 .and_then(Value::as_str)
12422 .is_none_or(|value| !is_sha256(value))
12423 {
12424 return Err(invalid_feed(
12425 "v2 grant revocation response is not authority-bound",
12426 ));
12427 }
12428 return Ok(response);
12429 }
12430 let _ = verified_remote_head(cfg, brain, false)?;
12431 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
12432 ensure_ok(
12433 request(cfg, "DELETE", &path, None, Auth::Required)?,
12434 "grant revoke",
12435 )
12436}
12437
12438#[derive(Debug)]
12443struct VerifiedV2Proposal {
12444 value: Value,
12445 changes: Value,
12446 blobs: Vec<(String, u64, String)>,
12447}
12448
12449fn require_proposal_id(id: &str) -> LinkResult<()> {
12450 if crate::ulid::is_ulid(id) {
12451 Ok(())
12452 } else {
12453 Err(invalid_feed("proposal id is not a lowercase ULID"))
12454 }
12455}
12456
12457fn verified_v2_proposal(
12458 cfg: &HubConfig,
12459 head: &V2VerifiedHead,
12460 proposal_id: &str,
12461) -> LinkResult<VerifiedV2Proposal> {
12462 require_proposal_id(proposal_id)?;
12463 if head.view_kind != "full" {
12464 return Err(invalid_feed(
12465 "proposal review requires a full readable view",
12466 ));
12467 }
12468 let path = format!(
12469 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
12470 head.brain_id
12471 );
12472 let value = ensure_ok(
12473 request_capped(
12474 cfg,
12475 "GET",
12476 &path,
12477 None,
12478 Auth::Required,
12479 MAX_FEED_RESPONSE_BYTES,
12480 )?,
12481 "v2 proposal",
12482 )?;
12483 verify_v2_proposal_value(head, proposal_id, value)
12484}
12485
12486fn verify_v2_proposal_value(
12487 head: &V2VerifiedHead,
12488 proposal_id: &str,
12489 value: Value,
12490) -> LinkResult<VerifiedV2Proposal> {
12491 if value.get("v").and_then(Value::as_u64) != Some(2) {
12492 return Err(invalid_feed("proposal response has an invalid version"));
12493 }
12494 let proposal = value
12495 .get("proposal")
12496 .and_then(Value::as_object)
12497 .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
12498 if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
12499 return Err(invalid_feed("proposal response changed its id"));
12500 }
12501 let payload_hash = proposal
12502 .get("payload_sha256")
12503 .and_then(Value::as_str)
12504 .filter(|hash| is_sha256(hash))
12505 .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
12506 let clear_hash = proposal
12507 .get("clear_sha256")
12508 .and_then(Value::as_str)
12509 .filter(|hash| is_sha256(hash))
12510 .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
12511 let submission_hash = proposal
12512 .get("submission_claim_sha256")
12513 .and_then(Value::as_str)
12514 .filter(|hash| is_sha256(hash))
12515 .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
12516 let submission = STANDARD
12517 .decode(
12518 proposal
12519 .get("submission_claim_base64")
12520 .and_then(Value::as_str)
12521 .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
12522 )
12523 .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
12524 let submission_value: Value = serde_json::from_slice(&submission)
12525 .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
12526 if crate::linkmd_v2::canonical_bytes(&submission_value)
12527 .map_err(|error| invalid_feed(error.to_string()))?
12528 != submission
12529 || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
12530 .map_err(|error| invalid_feed(error.to_string()))?
12531 != submission_hash
12532 {
12533 return Err(invalid_feed(
12534 "proposal submission claim is not canonical or addressed",
12535 ));
12536 }
12537 let envelope = submission_value
12538 .as_object()
12539 .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
12540 let claim = envelope
12541 .get("claim")
12542 .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
12543 let claim_object = claim
12544 .as_object()
12545 .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
12546 let actor_root = claim_object
12547 .get("actor_root")
12548 .and_then(Value::as_object)
12549 .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
12550 let public_key = envelope
12551 .get("public_key")
12552 .and_then(Value::as_str)
12553 .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
12554 let fingerprint = envelope
12555 .get("fingerprint")
12556 .and_then(Value::as_str)
12557 .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
12558 let signature = envelope
12559 .get("sig")
12560 .and_then(Value::as_str)
12561 .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
12562 let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
12563 .map_err(|error| invalid_feed(error.to_string()))?;
12564 let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
12565 let signer = format!("{fingerprint}:{public_key}");
12566 let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
12567 let grants = actor_root.get("grants").and_then(Value::as_array);
12568 let grants_are_canonical = grants.is_some_and(|items| {
12569 let mut prior: Option<&str> = None;
12570 items.iter().all(|item| {
12571 let Some(grant) = item.as_str() else {
12572 return false;
12573 };
12574 if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
12575 return false;
12576 }
12577 prior = Some(grant);
12578 true
12579 })
12580 });
12581 let optional_actor_field = |name: &str| {
12582 actor_root.get(name).is_some_and(|value| {
12583 value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
12584 })
12585 };
12586 let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
12587 if claim_object.get("v").and_then(Value::as_u64) != Some(2)
12588 || format!("{:x}", Sha256::digest(&der)) != fingerprint
12589 || head
12590 .trust
12591 .hub_signer
12592 .as_ref()
12593 .is_some_and(|known| known != &signer)
12594 || !matches!(
12595 actor_class,
12596 Some(
12597 "user"
12598 | "owned_agent"
12599 | "foreign_key"
12600 | "curation"
12601 | "inbox"
12602 | "restore"
12603 | "migration"
12604 | "operator_recovery"
12605 )
12606 )
12607 || actor_root
12608 .get("principal")
12609 .and_then(Value::as_str)
12610 .is_none_or(|value| value.is_empty())
12611 || actor_root
12612 .get("credential")
12613 .and_then(Value::as_str)
12614 .is_none_or(|value| value.is_empty())
12615 || !optional_actor_field("organization")
12616 || !optional_actor_field("role")
12617 || !grants_are_canonical
12618 || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
12619 || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12620 || !claim_object
12621 .get("mutation_id")
12622 .and_then(Value::as_str)
12623 .is_some_and(|value| {
12624 !value.is_empty()
12625 && value.len() <= 128
12626 && value.chars().enumerate().all(|(index, char)| {
12627 char.is_ascii_alphanumeric()
12628 || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
12629 })
12630 })
12631 || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
12632 || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
12633 || !claim_object
12634 .get("control_revision")
12635 .and_then(Value::as_str)
12636 .is_some_and(is_sha256)
12637 || submitted_at.is_none_or(|value| {
12638 chrono::DateTime::parse_from_rfc3339(value).is_err()
12639 || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
12640 })
12641 || !proposal
12642 .get("state")
12643 .and_then(Value::as_str)
12644 .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
12645 || proposal
12646 .get("expires_at")
12647 .and_then(Value::as_str)
12648 .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
12649 || proposal
12650 .get("proposer")
12651 .and_then(Value::as_object)
12652 .and_then(|value| value.get("class"))
12653 .and_then(Value::as_str)
12654 != actor_class
12655 {
12656 return Err(invalid_feed(
12657 "proposal submission claim does not bind the verified proposal",
12658 ));
12659 }
12660 let changes_b64 = proposal
12661 .get("changes_base64")
12662 .and_then(Value::as_str)
12663 .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
12664 let changes_bytes = STANDARD
12665 .decode(changes_b64)
12666 .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
12667 let changes: Value = serde_json::from_slice(&changes_bytes)
12668 .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
12669 if crate::linkmd_v2::canonical_bytes(&changes)
12670 .map_err(|error| invalid_feed(error.to_string()))?
12671 != changes_bytes
12672 || changes.get("v").and_then(Value::as_u64) != Some(2)
12673 || !changes.get("operations").is_some_and(Value::is_array)
12674 {
12675 return Err(invalid_feed("proposal changeset is not canonical v2"));
12676 }
12677 let blob_values = proposal
12678 .get("blobs")
12679 .and_then(Value::as_array)
12680 .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
12681 let mut blobs = Vec::with_capacity(blob_values.len());
12682 let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
12683 let mut prior_hash: Option<String> = None;
12684 for item in blob_values {
12685 let hash = item
12686 .get("sha256")
12687 .and_then(Value::as_str)
12688 .filter(|hash| is_sha256(hash))
12689 .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
12690 let bytes = item
12691 .get("bytes")
12692 .and_then(Value::as_u64)
12693 .filter(|bytes| *bytes <= MAX_STORE_BYTES)
12694 .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
12695 if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
12696 return Err(invalid_feed(
12697 "proposal blob declarations are not unique and sorted",
12698 ));
12699 }
12700 prior_hash = Some(hash.to_string());
12701 let endpoint = item
12702 .get("endpoint")
12703 .and_then(Value::as_str)
12704 .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
12705 let expected_endpoint = format!(
12706 "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
12707 head.brain_id
12708 );
12709 if endpoint != expected_endpoint {
12710 return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
12711 }
12712 descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
12713 blobs.push((hash.to_string(), bytes, endpoint.to_string()));
12714 }
12715 let descriptor = json!({
12716 "base": proposal.get("base").cloned().unwrap_or(Value::Null),
12717 "blobs": descriptor_blobs,
12718 "changes_base64": changes_b64,
12719 "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
12720 "v": 2,
12721 });
12722 let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
12723 .map_err(|error| invalid_feed(error.to_string()))?;
12724 if content_sha256(&descriptor_bytes) != clear_hash {
12725 return Err(invalid_feed(
12726 "proposal clear payload differs from its signed submission claim",
12727 ));
12728 }
12729 Ok(VerifiedV2Proposal {
12730 value,
12731 changes,
12732 blobs,
12733 })
12734}
12735
12736pub fn proposal_list(
12737 cfg: &HubConfig,
12738 brain: &str,
12739 state: &str,
12740 after: Option<&str>,
12741 limit: usize,
12742) -> LinkResult<Value> {
12743 require_safe_ref(brain)?;
12744 if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
12745 return Err(invalid_feed("proposal state is invalid"));
12746 }
12747 if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
12748 return Err(invalid_feed("proposal cursor is invalid"));
12749 }
12750 let head = v2_verified_head(cfg, brain)?
12751 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12752 let path = format!(
12753 "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
12754 head.brain_id,
12755 limit.clamp(1, 100),
12756 after.map_or_else(String::new, |value| format!("&after={value}"))
12757 );
12758 ensure_ok(
12759 request_capped(
12760 cfg,
12761 "GET",
12762 &path,
12763 None,
12764 Auth::Required,
12765 MAX_FEED_RESPONSE_BYTES,
12766 )?,
12767 "v2 proposal list",
12768 )
12769}
12770
12771pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
12772 require_safe_ref(brain)?;
12773 let head = v2_verified_head(cfg, brain)?
12774 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12775 Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
12776}
12777
12778pub fn proposal_reject(
12779 cfg: &HubConfig,
12780 brain: &str,
12781 proposal_id: &str,
12782 mutation_id: &str,
12783 reason: &str,
12784) -> LinkResult<Value> {
12785 require_safe_ref(brain)?;
12786 require_proposal_id(proposal_id)?;
12787 let head = v2_verified_head(cfg, brain)?
12788 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12789 let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
12790 let body = json!({
12791 "mutation_id": mutation_id,
12792 "control_revision": head.control_revision,
12793 "reason": reason,
12794 });
12795 let path = format!(
12796 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
12797 head.brain_id
12798 );
12799 ensure_ok(
12800 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
12801 "v2 proposal rejection",
12802 )
12803}
12804
12805pub fn proposal_accept_exact(
12806 cfg: &HubConfig,
12807 brain: &str,
12808 proposal_id: &str,
12809 mutation_id: &str,
12810 reason: &str,
12811) -> LinkResult<Value> {
12812 require_safe_ref(brain)?;
12813 require_proposal_id(proposal_id)?;
12814 let head = v2_verified_head(cfg, brain)?
12815 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12816 let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
12817 let operations = proposal
12818 .changes
12819 .get("operations")
12820 .and_then(Value::as_array)
12821 .cloned()
12822 .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
12823 if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
12824 return Err(invalid_feed("proposal operation count is invalid"));
12825 }
12826 let mut downloaded = std::collections::BTreeMap::new();
12827 for (hash, bytes, endpoint) in &proposal.blobs {
12828 let body = ensure_raw_ok(
12829 request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
12830 "v2 proposal blob",
12831 )?;
12832 if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
12833 return Err(invalid_feed("proposal blob does not match its declaration"));
12834 }
12835 downloaded.insert(hash.clone(), body);
12836 }
12837 let remote = files_for_v2_view(
12838 &head,
12839 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
12840 );
12841 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
12842 let mut expected_candidate = remote.clone();
12843 let mut expected_candidate_assets = remote_assets;
12844 for operation in &operations {
12845 let op = operation
12846 .get("op")
12847 .and_then(Value::as_str)
12848 .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
12849 match op {
12850 "put" | "put_asset_content" | "restore" => {
12851 let path = operation
12852 .get("path")
12853 .and_then(Value::as_str)
12854 .ok_or_else(|| invalid_feed("proposal write has no path"))?;
12855 crate::linkmd_v2::normalize_path(path)
12856 .map_err(|error| invalid_feed(error.to_string()))?;
12857 let hash = operation
12858 .get("blob")
12859 .and_then(Value::as_str)
12860 .filter(|hash| is_sha256(hash))
12861 .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
12862 let bytes = operation
12863 .get("bytes")
12864 .and_then(Value::as_u64)
12865 .ok_or_else(|| invalid_feed("proposal write has no size"))?;
12866 expected_candidate.insert(
12867 path.to_string(),
12868 V2BaselineFile {
12869 sha256: hash.to_string(),
12870 bytes,
12871 proof: None,
12872 },
12873 );
12874 }
12875 "delete" | "withdraw_from_hosting" => {
12876 let path = operation
12877 .get("path")
12878 .and_then(Value::as_str)
12879 .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
12880 crate::linkmd_v2::normalize_path(path)
12881 .map_err(|error| invalid_feed(error.to_string()))?;
12882 expected_candidate.remove(path);
12883 }
12884 "rename" => {
12885 let from = operation
12886 .get("from")
12887 .and_then(Value::as_str)
12888 .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
12889 let to = operation
12890 .get("to")
12891 .and_then(Value::as_str)
12892 .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
12893 crate::linkmd_v2::normalize_path(from)
12894 .and_then(|_| crate::linkmd_v2::normalize_path(to))
12895 .map_err(|error| invalid_feed(error.to_string()))?;
12896 let hash = operation
12897 .get("blob")
12898 .and_then(Value::as_str)
12899 .filter(|hash| is_sha256(hash))
12900 .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
12901 let bytes = operation
12902 .get("bytes")
12903 .and_then(Value::as_u64)
12904 .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
12905 expected_candidate.remove(from);
12906 expected_candidate.insert(
12907 to.to_string(),
12908 V2BaselineFile {
12909 sha256: hash.to_string(),
12910 bytes,
12911 proof: None,
12912 },
12913 );
12914 }
12915 "asset_delete" => {
12916 let path = operation
12917 .get("path")
12918 .and_then(Value::as_str)
12919 .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
12920 expected_candidate_assets.remove(path);
12921 }
12922 "asset_withdraw" => {
12923 let path = operation
12924 .get("path")
12925 .and_then(Value::as_str)
12926 .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
12927 if !expected_candidate_assets.contains_key(path) {
12928 return Err(invalid_feed("proposal withdraws an unknown asset"));
12929 }
12930 let Some(asset) = operation.get("asset").and_then(Value::as_object) else {
12931 let prior = expected_candidate_assets
12935 .get_mut(path)
12936 .expect("presence checked above");
12937 prior.disposition = "withheld".to_string();
12938 prior.leaf_hash.clear();
12939 continue;
12940 };
12941 let blob_sha256 = asset
12942 .get("blob_sha256")
12943 .and_then(Value::as_str)
12944 .filter(|hash| is_sha256(hash))
12945 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12946 let bytes = asset
12947 .get("bytes")
12948 .and_then(Value::as_u64)
12949 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12950 let media_type = asset
12951 .get("media_type")
12952 .and_then(Value::as_str)
12953 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12954 let wrappers = asset
12955 .get("wrappers")
12956 .and_then(Value::as_array)
12957 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12958 .iter()
12959 .map(|wrapper| {
12960 wrapper
12961 .as_str()
12962 .map(str::to_string)
12963 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12964 })
12965 .collect::<LinkResult<Vec<_>>>()?;
12966 let required = asset
12967 .get("required")
12968 .and_then(Value::as_bool)
12969 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12970 if asset.get("disposition").and_then(Value::as_str) != Some("withheld") {
12971 return Err(invalid_feed("proposal asset withdrawal is not withheld"));
12972 }
12973 expected_candidate_assets.insert(
12974 path.to_string(),
12975 V2BaselineAsset {
12976 blob_sha256: blob_sha256.to_string(),
12977 bytes,
12978 media_type: media_type.to_string(),
12979 wrappers,
12980 required,
12981 disposition: "withheld".to_string(),
12982 leaf_hash: String::new(),
12983 },
12984 );
12985 }
12986 "asset_put" | "asset_resume" => {
12987 let path = operation
12988 .get("path")
12989 .and_then(Value::as_str)
12990 .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
12991 let asset = operation
12992 .get("asset")
12993 .and_then(Value::as_object)
12994 .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
12995 let blob_sha256 = asset
12996 .get("blob_sha256")
12997 .and_then(Value::as_str)
12998 .filter(|hash| is_sha256(hash))
12999 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
13000 let bytes = asset
13001 .get("bytes")
13002 .and_then(Value::as_u64)
13003 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
13004 let media_type = asset
13005 .get("media_type")
13006 .and_then(Value::as_str)
13007 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
13008 let wrappers = asset
13009 .get("wrappers")
13010 .and_then(Value::as_array)
13011 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
13012 .iter()
13013 .map(|wrapper| {
13014 wrapper
13015 .as_str()
13016 .map(str::to_string)
13017 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
13018 })
13019 .collect::<LinkResult<Vec<_>>>()?;
13020 let required = asset
13021 .get("required")
13022 .and_then(Value::as_bool)
13023 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
13024 let disposition = asset
13025 .get("disposition")
13026 .and_then(Value::as_str)
13027 .filter(|value| matches!(*value, "hosted" | "withheld"))
13028 .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
13029 expected_candidate_assets.insert(
13030 path.to_string(),
13031 V2BaselineAsset {
13032 blob_sha256: blob_sha256.to_string(),
13033 bytes,
13034 media_type: media_type.to_string(),
13035 wrappers,
13036 required,
13037 disposition: disposition.to_string(),
13038 leaf_hash: String::new(),
13039 },
13040 );
13041 }
13042 _ => return Err(invalid_feed("proposal operation kind is unsupported")),
13043 }
13044 }
13045 let base = head.pointer.as_ref().map(|pointer| {
13046 json!({
13047 "seq": pointer.seq,
13048 "commit_hash": pointer.commit_hash,
13049 "content_root": pointer.content_root,
13050 "asset_root": pointer.asset_root,
13051 })
13052 });
13053 let mut body = json!({
13054 "mutation_id": mutation_id,
13055 "base": base,
13056 "rebase": "strict",
13057 "reason": reason,
13058 "operations": operations,
13059 "blobs": downloaded
13060 .iter()
13061 .map(|(sha256, bytes)| json!({
13062 "sha256": sha256,
13063 "bytes": bytes.len(),
13064 "content_base64": STANDARD.encode(bytes),
13065 }))
13066 .collect::<Vec<_>>(),
13067 "proposal_id": proposal_id,
13068 "proposal_mode": "exact",
13069 });
13070 let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
13071 total
13072 .checked_add(bytes.len())
13073 .ok_or_else(|| LinkError::PushTooLarge {
13074 detail: "proposal changed-byte total overflow".to_string(),
13075 })
13076 })?;
13077 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
13078 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
13079 for operation in &operations {
13080 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
13081 return Err(invalid_feed("proposal upload operation has no kind"));
13082 };
13083 let hash = match kind {
13084 "put" | "put_asset_content" | "restore" | "rename" => {
13085 operation.get("blob").and_then(Value::as_str)
13086 }
13087 "asset_put" | "asset_resume" => operation
13088 .get("asset")
13089 .and_then(|asset| asset.get("blob_sha256"))
13090 .and_then(Value::as_str),
13091 _ => None,
13092 };
13093 let Some(hash) = hash else { continue };
13094 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
13095 if kind == "rename" {
13096 for field in ["from", "to"] {
13097 coordinates.insert(
13098 operation
13099 .get(field)
13100 .and_then(Value::as_str)
13101 .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
13102 .to_string(),
13103 );
13104 }
13105 } else {
13106 let path = operation
13107 .get("path")
13108 .and_then(Value::as_str)
13109 .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
13110 coordinates.insert(if kind.starts_with("asset_") {
13111 format!("assets/{path}")
13112 } else {
13113 path.to_string()
13114 });
13115 }
13116 }
13117 let declarations = downloaded
13118 .iter()
13119 .map(|(sha256, bytes)| {
13120 json!({
13121 "sha256": sha256,
13122 "bytes": bytes.len(),
13123 "coordinates": coordinates_by_hash
13124 .get(sha256)
13125 .into_iter()
13126 .flatten()
13127 .collect::<Vec<_>>(),
13128 })
13129 })
13130 .collect::<Vec<_>>();
13131 let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
13132 for batch in batch_upload_declarations(declarations) {
13133 let reserved = reserve_upload_window(
13134 cfg,
13135 &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
13136 &json!({ "blobs": batch }),
13137 "prepare proposal blob transport",
13138 )?;
13139 let reserved_items = reserved
13140 .get("uploads")
13141 .and_then(Value::as_array)
13142 .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
13143 items.extend(reserved_items.iter().cloned());
13144 }
13145 if items.len() != downloaded.len() {
13146 return Err(invalid_feed("proposal upload reservation changed the set"));
13147 }
13148 let mut references = Vec::with_capacity(items.len());
13149 for item in items {
13150 let hash = item
13151 .get("sha256")
13152 .and_then(Value::as_str)
13153 .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
13154 let bytes = downloaded
13155 .get(hash)
13156 .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
13157 let reservation_id = item
13158 .get("reservation_id")
13159 .and_then(Value::as_str)
13160 .filter(|id| crate::ulid::is_ulid(id))
13161 .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
13162 let expected_coordinates = coordinates_by_hash
13163 .get(hash)
13164 .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
13165 let returned_coordinates = item
13166 .get("coordinates")
13167 .and_then(Value::as_array)
13168 .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
13169 if returned_coordinates.len() != expected_coordinates.len()
13170 || returned_coordinates
13171 .iter()
13172 .zip(expected_coordinates)
13173 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
13174 {
13175 return Err(invalid_feed(
13176 "proposal upload reservation changed its coordinates",
13177 ));
13178 }
13179 match item.get("status").and_then(Value::as_str) {
13180 Some("upload") => put_presigned(
13181 cfg,
13182 item.get("url")
13183 .and_then(Value::as_str)
13184 .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
13185 item.get("headers").unwrap_or(&Value::Null),
13186 bytes,
13187 )?,
13188 Some("already_present") => {}
13189 _ => return Err(invalid_feed("proposal upload status is invalid")),
13190 }
13191 references.push(json!({
13192 "sha256": hash,
13193 "bytes": bytes.len(),
13194 "reservation_id": reservation_id,
13195 }));
13196 }
13197 body["blobs"] = Value::Array(references);
13198 }
13199 stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
13203 let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
13204 let mut result = ensure_ok(
13205 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
13206 "exact proposal acceptance",
13207 )?;
13208 let mut candidate_hub_signer = None;
13209 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
13210 let request_id = result
13211 .get("request_id")
13212 .and_then(Value::as_str)
13213 .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
13214 .to_string();
13215 let challenge = result
13216 .get("signing_challenge")
13217 .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
13218 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
13219 cfg,
13220 &head,
13221 &expected_candidate,
13222 &expected_candidate_assets,
13223 mutation_id,
13224 &v2_signed_request_view(&body, &operations),
13225 challenge,
13226 )?;
13227 body["signing_challenge_id"] = Value::String(challenge_id);
13228 body["signature_base64url"] = Value::String(signature);
13229 candidate_hub_signer = Some(actor_signer);
13230 result = ensure_ok(
13231 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
13232 "signed exact proposal acceptance",
13233 )?;
13234 }
13235 let refreshed = v2_verified_head(cfg, brain)?
13236 .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
13237 if candidate_hub_signer
13238 .as_ref()
13239 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
13240 || refreshed
13241 .pointer
13242 .as_ref()
13243 .map(|pointer| pointer.commit_hash.as_str())
13244 != result.get("commit_hash").and_then(Value::as_str)
13245 || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
13246 || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
13247 {
13248 return Err(LinkError::RemoteAdvancedDuringSync);
13249 }
13250 accept_v2_head(cfg, &refreshed)?;
13251 Ok(result)
13252}
13253
13254pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
13265 require_valid_handle(handle)?;
13266 if body.len() as u64 > MAX_PROPOSE_BYTES {
13267 return Err(LinkError::ProposeTooLarge {
13268 bytes: body.len() as u64,
13269 });
13270 }
13271 let payload = json!({ "app": app, "body": body });
13272 let (path, auth) = if crate::ulid::is_ulid(handle) {
13277 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
13278 } else {
13279 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
13280 };
13281 ensure_ok(
13282 request(cfg, "POST", &path, Some(&payload), auth)?,
13283 "propose",
13284 )
13285}
13286
13287#[derive(Debug, serde::Serialize)]
13293pub struct Head {
13294 pub brain: String,
13296 pub seq: u64,
13298 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
13300 pub updated_at: Option<String>,
13301 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
13303 pub feed_hash: Option<String>,
13304 pub verified: bool,
13307}
13308
13309struct BoundedVecVisitor<T, const MAX: usize> {
13310 label: &'static str,
13311 marker: std::marker::PhantomData<T>,
13312}
13313
13314impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
13315where
13316 T: Deserialize<'de>,
13317{
13318 type Value = Vec<T>;
13319
13320 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13321 write!(formatter, "at most {MAX} {}", self.label)
13322 }
13323
13324 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
13325 where
13326 A: serde::de::SeqAccess<'de>,
13327 {
13328 if sequence.size_hint().is_some_and(|size| size > MAX) {
13329 return Err(serde::de::Error::custom(format!(
13330 "{} exceeds the {MAX}-item limit",
13331 self.label
13332 )));
13333 }
13334 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
13335 while let Some(value) = sequence.next_element()? {
13336 if values.len() == MAX {
13337 return Err(serde::de::Error::custom(format!(
13338 "{} exceeds the {MAX}-item limit",
13339 self.label
13340 )));
13341 }
13342 values.push(value);
13343 }
13344 Ok(values)
13345 }
13346}
13347
13348fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
13349 deserializer: D,
13350 label: &'static str,
13351) -> Result<Vec<T>, D::Error>
13352where
13353 D: serde::Deserializer<'de>,
13354 T: Deserialize<'de>,
13355{
13356 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
13357 label,
13358 marker: std::marker::PhantomData,
13359 })
13360}
13361
13362fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
13363where
13364 D: serde::Deserializer<'de>,
13365{
13366 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
13367}
13368
13369fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
13370where
13371 D: serde::Deserializer<'de>,
13372{
13373 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
13374}
13375
13376fn deserialize_previous_identities<'de, D>(
13377 deserializer: D,
13378) -> Result<Vec<PreviousIdentity>, D::Error>
13379where
13380 D: serde::Deserializer<'de>,
13381{
13382 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
13383 deserializer,
13384 "previous identities",
13385 )
13386}
13387
13388fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
13389where
13390 D: serde::Deserializer<'de>,
13391{
13392 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
13393 deserializer,
13394 "rotation statements",
13395 )
13396}
13397
13398fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
13399where
13400 D: serde::Deserializer<'de>,
13401{
13402 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
13403}
13404
13405#[derive(Debug, Clone, Deserialize, Serialize)]
13406struct FeedFile {
13407 path: String,
13408 sha256: String,
13409 bytes: u64,
13410}
13411
13412#[cfg(test)]
13413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13414enum V1DisclosureError {
13415 DuplicateFile,
13416 DuplicateRemoved,
13417 PushManifestMismatch,
13418 EditMissingChange,
13419 EditFalseFile,
13420 RemovedMismatch,
13421}
13422
13423#[cfg(test)]
13427fn verify_v1_manifest_disclosure(
13428 kind: &str,
13429 previous: &[FeedFile],
13430 resulting: &[FeedFile],
13431 files: &[FeedFile],
13432 removed: &[String],
13433) -> Result<(), V1DisclosureError> {
13434 fn as_map(
13435 files: &[FeedFile],
13436 ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
13437 let mut result = std::collections::BTreeMap::new();
13438 for file in files {
13439 if result
13440 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
13441 .is_some()
13442 {
13443 return Err(V1DisclosureError::DuplicateFile);
13444 }
13445 }
13446 Ok(result)
13447 }
13448 let previous = as_map(previous)?;
13449 let resulting = as_map(resulting)?;
13450 let disclosed = as_map(files)?;
13451 let removed_set: std::collections::BTreeSet<&str> =
13452 removed.iter().map(String::as_str).collect();
13453 if removed_set.len() != removed.len() {
13454 return Err(V1DisclosureError::DuplicateRemoved);
13455 }
13456 let expected_removed: std::collections::BTreeSet<&str> = previous
13457 .keys()
13458 .copied()
13459 .filter(|path| !resulting.contains_key(path))
13460 .collect();
13461 if removed_set != expected_removed {
13462 return Err(V1DisclosureError::RemovedMismatch);
13463 }
13464 if kind == "push" {
13465 return if disclosed == resulting {
13466 Ok(())
13467 } else {
13468 Err(V1DisclosureError::PushManifestMismatch)
13469 };
13470 }
13471 if kind != "edit" {
13472 return Err(V1DisclosureError::EditFalseFile);
13473 }
13474 if disclosed
13475 .iter()
13476 .any(|(path, value)| resulting.get(path) != Some(value))
13477 {
13478 return Err(V1DisclosureError::EditFalseFile);
13479 }
13480 for (path, value) in &resulting {
13481 if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
13482 return Err(V1DisclosureError::EditMissingChange);
13483 }
13484 }
13485 Ok(())
13486}
13487
13488#[derive(Debug, Clone, Deserialize, Serialize)]
13489struct FeedEntry {
13490 v: u8,
13491 seq: u64,
13492 ts: String,
13493 brain: String,
13494 public_key: String,
13495 kind: String,
13496 op: String,
13497 pack_sha256: String,
13498 #[serde(deserialize_with = "deserialize_feed_files")]
13499 files: Vec<FeedFile>,
13500 #[serde(deserialize_with = "deserialize_removed_paths")]
13501 removed: Vec<String>,
13502 prev_entry_hash: Option<String>,
13503 sig: String,
13504}
13505
13506#[derive(Serialize)]
13507struct UnsignedFeedEntry<'a> {
13508 v: u8,
13509 seq: u64,
13510 ts: &'a str,
13511 brain: &'a str,
13512 public_key: &'a str,
13513 kind: &'a str,
13514 op: &'a str,
13515 pack_sha256: &'a str,
13516 files: &'a [FeedFile],
13517 removed: &'a [String],
13518 prev_entry_hash: &'a Option<String>,
13519}
13520
13521#[derive(Debug, Clone, Deserialize, Serialize)]
13522struct FeedItem {
13523 hash: String,
13524 entry: FeedEntry,
13525}
13526
13527#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
13528struct FeedIdentity {
13529 fingerprint: String,
13530 #[serde(rename = "publicKeySpki")]
13531 public_key_spki: String,
13532 #[serde(default, deserialize_with = "deserialize_previous_identities")]
13536 previous: Vec<PreviousIdentity>,
13537 #[serde(default, deserialize_with = "deserialize_rotations")]
13540 rotations: Vec<String>,
13541}
13542
13543#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
13544struct PreviousIdentity {
13545 fingerprint: String,
13546 #[serde(rename = "publicKeySpki")]
13547 public_key_spki: String,
13548}
13549
13550#[derive(Debug, Deserialize)]
13551struct FeedResponse {
13552 #[serde(rename = "headSeq")]
13553 head_seq: u64,
13554 #[serde(rename = "feedHash")]
13555 feed_hash: Option<String>,
13556 identity: Option<FeedIdentity>,
13557 #[serde(deserialize_with = "deserialize_feed_items")]
13558 entries: Vec<FeedItem>,
13559 #[serde(rename = "scopeLimited")]
13560 scope_limited: bool,
13561}
13562
13563#[derive(Debug, Deserialize, Serialize)]
13564#[serde(deny_unknown_fields)]
13565struct RotationStatement {
13566 v: u8,
13567 op: String,
13568 brain: String,
13569 public_key: String,
13570 new_brain: String,
13571 new_public_key: String,
13572 prior_head_seq: u64,
13573 prior_feed_hash: Option<String>,
13574 ts: String,
13575 sig: String,
13576}
13577
13578#[derive(Debug, Clone, Deserialize, Serialize)]
13579struct TrustState {
13580 v: u8,
13581 origin: String,
13582 #[serde(default)]
13586 requested: String,
13587 brain: String,
13589 #[serde(default, skip_serializing_if = "Option::is_none")]
13592 home: Option<String>,
13593 anchor: String,
13594 current: String,
13595 #[serde(rename = "headSeq")]
13596 head_seq: u64,
13597 #[serde(rename = "feedHash")]
13598 feed_hash: Option<String>,
13599 #[serde(default)]
13603 rotations: Vec<String>,
13604 #[serde(default, skip_serializing_if = "Option::is_none")]
13607 hub_signer: Option<String>,
13608 #[serde(default, skip_serializing_if = "Option::is_none")]
13611 protocol_profile: Option<String>,
13612}
13613
13614fn accepted_as_v2(state: &TrustState) -> bool {
13615 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
13616}
13617
13618fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
13619 let directory = open_trust_dir(cfg)?;
13620 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
13621 return Ok(true);
13622 }
13623 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
13624 return Ok(false);
13625 };
13626 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
13627}
13628
13629#[derive(Debug, Clone, Deserialize, Serialize)]
13630struct AliasBinding {
13631 v: u8,
13632 origin: String,
13633 requested: String,
13634 brain: String,
13635 #[serde(default, skip_serializing_if = "Option::is_none")]
13636 home: Option<String>,
13637}
13638
13639struct VerifiedRemote {
13640 head: Head,
13641 identity: Option<FeedIdentity>,
13642 head_entry: Option<FeedItem>,
13643 entries: Vec<FeedItem>,
13645 anchor: Option<String>,
13646}
13647
13648fn invalid_feed(message: impl Into<String>) -> LinkError {
13649 LinkError::InvalidFeed {
13650 message: message.into(),
13651 }
13652}
13653
13654fn is_sha256(value: &str) -> bool {
13655 value.len() == 64
13656 && value
13657 .bytes()
13658 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
13659}
13660
13661fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
13662 let der = URL_SAFE_NO_PAD
13663 .decode(public_key_spki)
13664 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
13665 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
13666 return Err(invalid_feed(
13667 "identity public key is not a valid Ed25519 SPKI",
13668 ));
13669 }
13670 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
13671}
13672
13673fn verify_identity_chain(
13677 identity: &FeedIdentity,
13678 pinned: Option<&TrustState>,
13679) -> LinkResult<String> {
13680 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
13681 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
13682 {
13683 return Err(invalid_feed(
13684 "identity rotation history exceeds the client cap",
13685 ));
13686 }
13687 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
13688 return Err(invalid_feed(
13689 "current identity fingerprint does not match its public key",
13690 ));
13691 }
13692 for previous in &identity.previous {
13693 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
13694 return Err(invalid_feed(
13695 "previous identity fingerprint does not match its public key",
13696 ));
13697 }
13698 }
13699 if identity.rotations.len() != identity.previous.len() {
13700 return Err(invalid_feed(
13701 "identity history is missing an old-key-signed rotation statement",
13702 ));
13703 }
13704
13705 let mut chain: Vec<(&str, &str)> = identity
13709 .previous
13710 .iter()
13711 .rev()
13712 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
13713 .collect();
13714 chain.push((&identity.fingerprint, &identity.public_key_spki));
13715
13716 for (index, raw) in identity.rotations.iter().enumerate() {
13717 let statement: RotationStatement = serde_json::from_str(raw)
13718 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
13719 let (old_fingerprint, old_spki) = chain[index];
13720 let (new_fingerprint, new_spki) = chain[index + 1];
13721 if statement.v != 1
13722 || statement.op != "rotate"
13723 || statement.brain != format!("ed25519:{old_fingerprint}")
13724 || statement.public_key != old_spki
13725 || statement.new_brain != format!("ed25519:{new_fingerprint}")
13726 || statement.new_public_key != new_spki
13727 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
13728 || (statement.prior_head_seq > 0
13729 && statement
13730 .prior_feed_hash
13731 .as_deref()
13732 .is_none_or(|hash| !is_sha256(hash)))
13733 {
13734 return Err(invalid_feed(
13735 "rotation statement does not connect adjacent identities",
13736 ));
13737 }
13738 let unsigned = serde_json::to_string(&UnsignedRotation {
13739 v: statement.v,
13740 op: &statement.op,
13741 brain: &statement.brain,
13742 public_key: &statement.public_key,
13743 new_brain: &statement.new_brain,
13744 new_public_key: &statement.new_public_key,
13745 prior_head_seq: statement.prior_head_seq,
13746 prior_feed_hash: statement.prior_feed_hash.as_deref(),
13747 ts: statement.ts.clone(),
13748 })
13749 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
13750 let exact = format!(
13751 "{},\"sig\":\"{}\"}}",
13752 &unsigned[..unsigned.len() - 1],
13753 statement.sig
13754 );
13755 if exact != *raw {
13756 return Err(invalid_feed(
13757 "rotation statement is not in normative serialization",
13758 ));
13759 }
13760 let der = URL_SAFE_NO_PAD
13761 .decode(old_spki)
13762 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
13763 let signature = URL_SAFE_NO_PAD
13764 .decode(&statement.sig)
13765 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
13766 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
13767 .verify(unsigned.as_bytes(), &signature)
13768 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
13769 if index > 0 {
13770 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
13771 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
13772 if statement.prior_head_seq < prior.prior_head_seq {
13773 return Err(invalid_feed("rotation feed boundaries move backward"));
13774 }
13775 }
13776 }
13777
13778 let anchor = format!("ed25519:{}", chain[0].0);
13779 let current = format!("ed25519:{}", identity.fingerprint);
13780 if let Some(pin) = pinned {
13781 if pin.anchor != anchor {
13782 return Err(invalid_feed(
13783 "served identity chain does not descend from the pinned anchor",
13784 ));
13785 }
13786 if !chain
13787 .iter()
13788 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
13789 {
13790 return Err(invalid_feed(
13791 "served identity chain forked away from the last pinned identity",
13792 ));
13793 }
13794 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
13795 return Err(invalid_feed("served identity discarded its rotation chain"));
13796 }
13797 if pin.v >= 2
13798 && (identity.rotations.len() < pin.rotations.len()
13799 || identity.rotations[..pin.rotations.len()] != pin.rotations)
13800 {
13801 return Err(invalid_feed(
13802 "served identity rewrote the locally accepted rotation history",
13803 ));
13804 }
13805 }
13806 Ok(anchor)
13807}
13808
13809fn verify_rotation_feed_boundaries(
13810 identity: &FeedIdentity,
13811 pinned: Option<&TrustState>,
13812 observed: &[FeedItem],
13813 advertised_seq: u64,
13814) -> LinkResult<()> {
13815 let mut chain: Vec<String> = identity
13816 .previous
13817 .iter()
13818 .rev()
13819 .map(|previous| format!("ed25519:{}", previous.fingerprint))
13820 .collect();
13821 chain.push(format!("ed25519:{}", identity.fingerprint));
13822 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
13823
13824 for (index, raw) in identity.rotations.iter().enumerate() {
13825 let rotation: RotationStatement = serde_json::from_str(raw)
13826 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13827 if rotation.prior_head_seq > advertised_seq {
13828 return Err(invalid_feed(
13829 "rotation claims a feed boundary beyond the advertised head",
13830 ));
13831 }
13832 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
13833 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
13834 return Err(invalid_feed(
13835 "newly disclosed rotation predates the local feed checkpoint",
13836 ));
13837 }
13838 }
13839 let actual = if rotation.prior_head_seq == 0 {
13840 None
13841 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
13842 pinned.and_then(|pin| pin.feed_hash.as_deref())
13843 } else {
13844 observed
13845 .iter()
13846 .find(|item| item.entry.seq == rotation.prior_head_seq)
13847 .map(|item| item.hash.as_str())
13848 };
13849 if let Some(actual) = actual {
13850 if rotation.prior_feed_hash.as_deref() != Some(actual) {
13851 return Err(invalid_feed(
13852 "rotation statement does not commit the verified feed boundary",
13853 ));
13854 }
13855 } else if rotation.prior_head_seq == 0 {
13856 } else if pinned.is_some_and(|pin| {
13859 pinned_index.is_some_and(|pin_index| index >= pin_index)
13860 || rotation.prior_head_seq >= pin.head_seq
13861 }) {
13862 return Err(invalid_feed(
13863 "rotation feed boundary was not present in the verified chain",
13864 ));
13865 }
13866 }
13867 Ok(())
13868}
13869
13870fn reject_retired_signer_after_checkpoint(
13875 identity: &FeedIdentity,
13876 pinned: Option<&TrustState>,
13877 item: &FeedItem,
13878) -> LinkResult<()> {
13879 let Some(pin) = pinned else {
13880 return Ok(());
13881 };
13882 if item.entry.seq <= pin.head_seq {
13883 return Ok(());
13884 }
13885 let mut chain: Vec<String> = identity
13886 .previous
13887 .iter()
13888 .rev()
13889 .map(|previous| format!("ed25519:{}", previous.fingerprint))
13890 .collect();
13891 chain.push(format!("ed25519:{}", identity.fingerprint));
13892 let pinned_index = chain
13893 .iter()
13894 .position(|key| key == &pin.current)
13895 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
13896 let signer_index = chain
13897 .iter()
13898 .position(|key| key == &item.entry.brain)
13899 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
13900 if signer_index < pinned_index {
13901 return Err(invalid_feed(
13902 "a retired identity attempted to sign after the local checkpoint",
13903 ));
13904 }
13905 Ok(())
13906}
13907
13908fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
13909 let origin = normalized_origin(&cfg.hub)?;
13910 let key = format!(
13911 "{:x}",
13912 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
13913 );
13914 Ok(format!("{key}.json"))
13915}
13916
13917fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
13918 let origin = normalized_origin(&cfg.hub)?;
13919 let key = format!(
13920 "{:x}",
13921 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
13922 );
13923 Ok(format!("alias-{key}.json"))
13924}
13925
13926#[cfg(any(unix, windows))]
13927struct TrustLock {
13928 _file: std::fs::File,
13929}
13930
13931#[cfg(unix)]
13932fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13933 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13934
13935 let lock_string = format!(".{state_name}.lock");
13936 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
13937 let fd = unsafe {
13938 libc::openat(
13939 directory.as_raw_fd(),
13940 lock_name.as_ptr(),
13941 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13942 0o600,
13943 )
13944 };
13945 if fd < 0 {
13946 return Err(std::io::Error::last_os_error().into());
13947 }
13948 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13949 if !file.metadata()?.is_file() {
13950 return Err(LinkError::UnsafePath { path: lock_string });
13951 }
13952 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
13953 return Err(std::io::Error::last_os_error().into());
13954 }
13955 Ok(TrustLock { _file: file })
13956}
13957
13958#[cfg(windows)]
13959fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13960 let lock_name = format!(".{state_name}.lock");
13961 let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
13962 Ok(TrustLock { _file: file })
13963}
13964
13965#[cfg(any(unix, windows))]
13966fn lock_trust_many(
13967 cfg: &HubConfig,
13968 directory: &std::fs::File,
13969 refs: &[&str],
13970) -> LinkResult<Vec<TrustLock>> {
13971 let mut names = refs
13972 .iter()
13973 .map(|reference| trust_file_name(cfg, reference))
13974 .collect::<LinkResult<Vec<_>>>()?;
13975 names.sort();
13976 names.dedup();
13977 names
13978 .iter()
13979 .map(|name| lock_trust_name(directory, name))
13980 .collect()
13981}
13982
13983#[cfg(not(any(unix, windows)))]
13984fn lock_trust_many(
13985 _cfg: &HubConfig,
13986 _directory: &TrustDirectory,
13987 _refs: &[&str],
13988) -> LinkResult<Vec<()>> {
13989 Err(LinkError::UnsupportedPlatform {
13990 operation: "verified link.md state",
13991 })
13992}
13993
13994#[cfg(any(unix, windows))]
13995type TrustDirectory = std::fs::File;
13996
13997#[cfg(not(any(unix, windows)))]
13998struct TrustDirectory;
13999
14000#[cfg(unix)]
14001fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
14002 use std::os::fd::AsRawFd as _;
14003
14004 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
14005 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
14006 return Err(std::io::Error::last_os_error().into());
14007 }
14008 directory.sync_all()?;
14009 Ok(directory)
14010}
14011
14012#[cfg(windows)]
14013fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
14014 let marker = cfg.state_dir.join("trust").join(".directory");
14015 crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
14016 Ok(crate::fsx::open_directory_nofollow(
14017 marker.parent().expect("trust marker has a parent"),
14018 )?)
14019}
14020
14021#[cfg(not(any(unix, windows)))]
14022fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
14023 Err(LinkError::UnsupportedPlatform {
14024 operation: "verified link.md state",
14025 })
14026}
14027
14028#[cfg(unix)]
14029fn load_trust_in(
14030 cfg: &HubConfig,
14031 directory: &TrustDirectory,
14032 requested: &str,
14033) -> LinkResult<Option<TrustState>> {
14034 use std::os::fd::{AsRawFd as _, FromRawFd as _};
14035
14036 let name_string = trust_file_name(cfg, requested)?;
14037 let name = c_name(name_string.as_bytes(), &name_string)?;
14038 let fd = unsafe {
14039 libc::openat(
14040 directory.as_raw_fd(),
14041 name.as_ptr(),
14042 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14043 )
14044 };
14045 if fd < 0 {
14046 let error = std::io::Error::last_os_error();
14047 if error.kind() == std::io::ErrorKind::NotFound {
14048 return Ok(None);
14049 }
14050 return Err(LinkError::UnsafePath { path: name_string });
14051 }
14052 let file = unsafe { std::fs::File::from_raw_fd(fd) };
14053 if !file.metadata()?.is_file() {
14054 return Err(LinkError::UnsafePath { path: name_string });
14055 }
14056 let mut bytes = Vec::new();
14057 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
14058 if bytes.len() > 1024 * 1024 {
14059 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
14060 }
14061 let mut state: TrustState = serde_json::from_slice(&bytes)
14062 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
14063 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
14064 return Err(invalid_feed(
14065 "local identity/feed checkpoint does not match this hub and brain",
14066 ));
14067 }
14068 if state.v == 1 {
14069 if state.brain != requested {
14073 return Err(invalid_feed(
14074 "legacy checkpoint is not bound to the requested brain id",
14075 ));
14076 }
14077 state.requested = requested.to_string();
14078 } else if state.requested != requested {
14079 return Err(invalid_feed(
14080 "local identity/feed checkpoint is bound to a different requested ref",
14081 ));
14082 }
14083 Ok(Some(state))
14084}
14085
14086#[cfg(windows)]
14087fn load_trust_in(
14088 cfg: &HubConfig,
14089 directory: &TrustDirectory,
14090 requested: &str,
14091) -> LinkResult<Option<TrustState>> {
14092 let name = trust_file_name(cfg, requested)?;
14093 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
14094 let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
14095 Ok(bytes) => bytes,
14096 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
14097 Err(_) => return Err(LinkError::UnsafePath { path: name }),
14098 };
14099 let mut state: TrustState = serde_json::from_slice(&bytes)
14100 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
14101 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
14102 return Err(invalid_feed(
14103 "local identity/feed checkpoint does not match this hub and brain",
14104 ));
14105 }
14106 if state.v == 1 {
14107 if state.brain != requested {
14108 return Err(invalid_feed(
14109 "legacy checkpoint is not bound to the requested brain id",
14110 ));
14111 }
14112 state.requested = requested.to_string();
14113 } else if state.requested != requested {
14114 return Err(invalid_feed(
14115 "local identity/feed checkpoint is bound to a different requested ref",
14116 ));
14117 }
14118 Ok(Some(state))
14119}
14120
14121#[cfg(not(any(unix, windows)))]
14122fn load_trust_in(
14123 _cfg: &HubConfig,
14124 _directory: &TrustDirectory,
14125 _brain: &str,
14126) -> LinkResult<Option<TrustState>> {
14127 Err(LinkError::UnsupportedPlatform {
14128 operation: "verified link.md state",
14129 })
14130}
14131
14132#[cfg(all(test, any(unix, windows)))]
14133fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
14134 let directory = open_trust_dir(cfg)?;
14135 load_trust_in(cfg, &directory, requested)
14136}
14137
14138#[cfg(unix)]
14139fn save_trust_in(
14140 cfg: &HubConfig,
14141 directory: &TrustDirectory,
14142 state: &TrustState,
14143) -> LinkResult<()> {
14144 use std::os::fd::{AsRawFd as _, FromRawFd as _};
14145
14146 let name_string = trust_file_name(cfg, &state.requested)?;
14147 let name = c_name(name_string.as_bytes(), &name_string)?;
14148 let mut bytes = serde_json::to_vec(state)
14149 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
14150 bytes.push(b'\n');
14151
14152 let nonce = std::time::SystemTime::now()
14153 .duration_since(std::time::UNIX_EPOCH)
14154 .unwrap_or_default()
14155 .as_nanos();
14156 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
14157 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
14158 let fd = unsafe {
14159 libc::openat(
14160 directory.as_raw_fd(),
14161 temp.as_ptr(),
14162 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14163 0o600,
14164 )
14165 };
14166 if fd < 0 {
14167 return Err(std::io::Error::last_os_error().into());
14168 }
14169 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
14170 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
14171 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14172 return Err(error.into());
14173 }
14174 drop(file);
14175 if unsafe {
14176 libc::renameat(
14177 directory.as_raw_fd(),
14178 temp.as_ptr(),
14179 directory.as_raw_fd(),
14180 name.as_ptr(),
14181 )
14182 } != 0
14183 {
14184 let error = std::io::Error::last_os_error();
14185 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14186 return Err(error.into());
14187 }
14188 directory.sync_all()?;
14189 Ok(())
14190}
14191
14192#[cfg(windows)]
14193fn save_trust_in(
14194 cfg: &HubConfig,
14195 directory: &TrustDirectory,
14196 state: &TrustState,
14197) -> LinkResult<()> {
14198 let name = trust_file_name(cfg, &state.requested)?;
14199 let mut bytes = serde_json::to_vec(state)
14200 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
14201 bytes.push(b'\n');
14202 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
14203 Ok(())
14204}
14205
14206#[cfg(not(any(unix, windows)))]
14207fn save_trust_in(
14208 _cfg: &HubConfig,
14209 _directory: &TrustDirectory,
14210 _state: &TrustState,
14211) -> LinkResult<()> {
14212 Err(LinkError::UnsupportedPlatform {
14213 operation: "verified link.md state",
14214 })
14215}
14216
14217#[cfg(unix)]
14218fn load_alias_in(
14219 cfg: &HubConfig,
14220 directory: &TrustDirectory,
14221 requested: &str,
14222) -> LinkResult<Option<AliasBinding>> {
14223 use std::os::fd::{AsRawFd as _, FromRawFd as _};
14224
14225 let name_string = alias_file_name(cfg, requested)?;
14226 let name = c_name(name_string.as_bytes(), &name_string)?;
14227 let fd = unsafe {
14228 libc::openat(
14229 directory.as_raw_fd(),
14230 name.as_ptr(),
14231 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14232 )
14233 };
14234 if fd < 0 {
14235 let error = std::io::Error::last_os_error();
14236 if error.kind() == std::io::ErrorKind::NotFound {
14237 return Ok(None);
14238 }
14239 return Err(LinkError::UnsafePath { path: name_string });
14240 }
14241 let file = unsafe { std::fs::File::from_raw_fd(fd) };
14242 if !file.metadata()?.is_file() {
14243 return Err(LinkError::UnsafePath { path: name_string });
14244 }
14245 let mut bytes = Vec::new();
14246 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
14247 if bytes.len() > 64 * 1024 {
14248 return Err(invalid_feed("local alias binding is oversized"));
14249 }
14250 let alias: AliasBinding = serde_json::from_slice(&bytes)
14251 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
14252 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
14253 {
14254 return Err(invalid_feed(
14255 "local alias binding does not match this hub and requested ref",
14256 ));
14257 }
14258 Ok(Some(alias))
14259}
14260
14261#[cfg(windows)]
14262fn load_alias_in(
14263 cfg: &HubConfig,
14264 directory: &TrustDirectory,
14265 requested: &str,
14266) -> LinkResult<Option<AliasBinding>> {
14267 let name = alias_file_name(cfg, requested)?;
14268 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
14269 let bytes = match reader.read(Path::new(&name), 64 * 1024) {
14270 Ok(bytes) => bytes,
14271 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
14272 Err(_) => return Err(LinkError::UnsafePath { path: name }),
14273 };
14274 let alias: AliasBinding = serde_json::from_slice(&bytes)
14275 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
14276 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
14277 {
14278 return Err(invalid_feed(
14279 "local alias binding does not match this hub and requested ref",
14280 ));
14281 }
14282 Ok(Some(alias))
14283}
14284
14285#[cfg(not(any(unix, windows)))]
14286fn load_alias_in(
14287 _cfg: &HubConfig,
14288 _directory: &TrustDirectory,
14289 _requested: &str,
14290) -> LinkResult<Option<AliasBinding>> {
14291 Err(LinkError::UnsupportedPlatform {
14292 operation: "verified link.md state",
14293 })
14294}
14295
14296#[cfg(unix)]
14297fn save_alias_in(
14298 cfg: &HubConfig,
14299 directory: &TrustDirectory,
14300 alias: &AliasBinding,
14301) -> LinkResult<()> {
14302 use std::os::fd::{AsRawFd as _, FromRawFd as _};
14303
14304 let name_string = alias_file_name(cfg, &alias.requested)?;
14305 let name = c_name(name_string.as_bytes(), &name_string)?;
14306 let mut bytes = serde_json::to_vec(alias)
14307 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
14308 bytes.push(b'\n');
14309 let nonce = std::time::SystemTime::now()
14310 .duration_since(std::time::UNIX_EPOCH)
14311 .unwrap_or_default()
14312 .as_nanos();
14313 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
14314 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
14315 let fd = unsafe {
14316 libc::openat(
14317 directory.as_raw_fd(),
14318 temp.as_ptr(),
14319 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14320 0o600,
14321 )
14322 };
14323 if fd < 0 {
14324 return Err(std::io::Error::last_os_error().into());
14325 }
14326 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
14327 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
14328 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14329 return Err(error.into());
14330 }
14331 drop(file);
14332 if unsafe {
14333 libc::renameat(
14334 directory.as_raw_fd(),
14335 temp.as_ptr(),
14336 directory.as_raw_fd(),
14337 name.as_ptr(),
14338 )
14339 } != 0
14340 {
14341 let error = std::io::Error::last_os_error();
14342 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14343 return Err(error.into());
14344 }
14345 directory.sync_all()?;
14346 Ok(())
14347}
14348
14349#[cfg(windows)]
14350fn save_alias_in(
14351 cfg: &HubConfig,
14352 directory: &TrustDirectory,
14353 alias: &AliasBinding,
14354) -> LinkResult<()> {
14355 let name = alias_file_name(cfg, &alias.requested)?;
14356 let mut bytes = serde_json::to_vec(alias)
14357 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
14358 bytes.push(b'\n');
14359 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
14360 Ok(())
14361}
14362
14363#[cfg(not(any(unix, windows)))]
14364fn save_alias_in(
14365 _cfg: &HubConfig,
14366 _directory: &TrustDirectory,
14367 _alias: &AliasBinding,
14368) -> LinkResult<()> {
14369 Err(LinkError::UnsupportedPlatform {
14370 operation: "verified link.md state",
14371 })
14372}
14373
14374fn load_canonical_pin(
14379 cfg: &HubConfig,
14380 directory: &TrustDirectory,
14381 requested: &str,
14382 resolved_brain: &str,
14383) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
14384 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
14385 if requested == resolved_brain {
14386 return Ok((canonical, None));
14387 }
14388
14389 let mut alias = load_alias_in(cfg, directory, requested)?;
14390 if let Some(binding) = &alias {
14391 if binding.brain != resolved_brain {
14392 return Err(LinkError::AliasRebindRequired {
14393 alias: requested.to_string(),
14394 from: binding.brain.clone(),
14395 to: resolved_brain.to_string(),
14396 });
14397 }
14398 return Ok((canonical, alias));
14399 }
14400
14401 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
14405 if legacy.brain != resolved_brain {
14406 return Err(invalid_feed(
14407 "legacy alias checkpoint names a different canonical brain",
14408 ));
14409 }
14410 if let Some(existing) = &canonical {
14411 if existing.brain != legacy.brain
14412 || existing.anchor != legacy.anchor
14413 || existing.current != legacy.current
14414 || existing.head_seq != legacy.head_seq
14415 || existing.feed_hash != legacy.feed_hash
14416 || existing.rotations != legacy.rotations
14417 {
14418 return Err(invalid_feed(
14419 "legacy alias checkpoint conflicts with the canonical checkpoint",
14420 ));
14421 }
14422 } else {
14423 let mut promoted = legacy.clone();
14424 promoted.requested = resolved_brain.to_string();
14425 promoted.home = None;
14426 save_trust_in(cfg, directory, &promoted)?;
14427 canonical = Some(promoted);
14428 }
14429 alias = Some(AliasBinding {
14430 v: 1,
14431 origin: normalized_origin(&cfg.hub)?,
14432 requested: requested.to_string(),
14433 brain: resolved_brain.to_string(),
14434 home: legacy.home,
14435 });
14436 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
14437 }
14438 Ok((canonical, alias))
14439}
14440
14441pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
14446 require_hardened_filesystem("verified alias rebind")?;
14447 require_safe_ref(alias)?;
14448 require_safe_ref(from)?;
14449 require_safe_ref(to)?;
14450 if crate::ulid::is_ulid(alias)
14451 || !crate::ulid::is_ulid(from)
14452 || !crate::ulid::is_ulid(to)
14453 || from == to
14454 {
14455 return Err(LinkError::InvalidPack {
14456 message:
14457 "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
14458 .to_string(),
14459 });
14460 }
14461
14462 let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
14463 message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
14464 })?;
14465 accept_v2_head(cfg, &verified)?;
14466
14467 let alias_response = ensure_ok(
14468 request(
14469 cfg,
14470 "GET",
14471 &format!("/api/hub/brains/{alias}/v2/head"),
14472 None,
14473 Auth::Required,
14474 )?,
14475 "resolve alias for explicit rebind",
14476 )?;
14477 let resolved: V2HeadResponse = serde_json::from_value(alias_response)
14478 .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
14479 if resolved.v != 2 || resolved.brain_id != to {
14480 return Err(LinkError::RemoteAdvancedDuringSync);
14481 }
14482
14483 let directory = open_trust_dir(cfg)?;
14484 let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
14485 let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
14486 message: "the requested alias has no existing local binding to replace".to_string(),
14487 })?;
14488 if binding.brain != from {
14489 return Err(LinkError::AliasRebindRequired {
14490 alias: alias.to_string(),
14491 from: binding.brain,
14492 to: to.to_string(),
14493 });
14494 }
14495 save_alias_in(
14496 cfg,
14497 &directory,
14498 &AliasBinding {
14499 v: 1,
14500 origin: normalized_origin(&cfg.hub)?,
14501 requested: alias.to_string(),
14502 brain: to.to_string(),
14503 home: binding.home,
14504 },
14505 )?;
14506 Ok(json!({
14507 "v": 2,
14508 "alias": alias,
14509 "from": from,
14510 "to": to,
14511 "outcome": "alias_rebound",
14512 }))
14513}
14514
14515fn save_canonical_pin_and_alias(
14516 cfg: &HubConfig,
14517 directory: &TrustDirectory,
14518 requested: &str,
14519 resolved_brain: &str,
14520 mut state: TrustState,
14521 existing_alias: Option<&AliasBinding>,
14522) -> LinkResult<()> {
14523 state.requested = resolved_brain.to_string();
14524 state.brain = resolved_brain.to_string();
14525 state.home = None;
14526 save_trust_in(cfg, directory, &state)?;
14527 if requested != resolved_brain {
14528 save_alias_in(
14529 cfg,
14530 directory,
14531 &AliasBinding {
14532 v: 1,
14533 origin: normalized_origin(&cfg.hub)?,
14534 requested: requested.to_string(),
14535 brain: resolved_brain.to_string(),
14536 home: existing_alias.and_then(|alias| alias.home.clone()),
14537 },
14538 )?;
14539 }
14540 Ok(())
14541}
14542
14543fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
14544 const ED25519_SPKI_PREFIX: &[u8] = &[
14545 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
14546 ];
14547 let entry = &item.entry;
14548 let public_der = URL_SAFE_NO_PAD
14549 .decode(&entry.public_key)
14550 .map_err(|_| invalid_feed("public key is not base64url"))?;
14551 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
14552 || !public_der.starts_with(ED25519_SPKI_PREFIX)
14553 {
14554 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
14555 }
14556 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
14557 if entry.brain != format!("ed25519:{fingerprint}") {
14558 return Err(invalid_feed(
14559 "brain fingerprint does not match its public key",
14560 ));
14561 }
14562 let _ = verify_identity_chain(identity, None)?;
14564 let mut chain: Vec<(&str, &str)> = identity
14565 .previous
14566 .iter()
14567 .rev()
14568 .map(|previous| {
14569 (
14570 previous.fingerprint.as_str(),
14571 previous.public_key_spki.as_str(),
14572 )
14573 })
14574 .collect();
14575 chain.push((&identity.fingerprint, &identity.public_key_spki));
14576 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
14577 *known_fingerprint == fingerprint && *spki == entry.public_key
14578 });
14579 let Some(signer_index) = signer_index else {
14580 return Err(invalid_feed(
14581 "entry signer is not this brain's identity (current or rotated-from)",
14582 ));
14583 };
14584 let lower_boundary = if signer_index == 0 {
14585 None
14586 } else {
14587 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
14588 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
14589 Some(prior.prior_head_seq)
14590 };
14591 let upper_boundary = if signer_index == identity.rotations.len() {
14592 None
14593 } else {
14594 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
14595 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
14596 Some(next.prior_head_seq)
14597 };
14598 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
14599 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
14600 {
14601 return Err(invalid_feed(
14602 "entry signer is outside its authenticated rotation epoch",
14603 ));
14604 }
14605 let unsigned = UnsignedFeedEntry {
14606 v: entry.v,
14607 seq: entry.seq,
14608 ts: &entry.ts,
14609 brain: &entry.brain,
14610 public_key: &entry.public_key,
14611 kind: &entry.kind,
14612 op: &entry.op,
14613 pack_sha256: &entry.pack_sha256,
14614 files: &entry.files,
14615 removed: &entry.removed,
14616 prev_entry_hash: &entry.prev_entry_hash,
14617 };
14618 let message =
14619 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
14620 let signature = URL_SAFE_NO_PAD
14621 .decode(&entry.sig)
14622 .map_err(|_| invalid_feed("signature is not base64url"))?;
14623 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
14624 .verify(&message, &signature)
14625 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
14626
14627 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
14628 exact.push(b'\n');
14629 let actual_hash = format!("{:x}", Sha256::digest(&exact));
14630 if actual_hash != item.hash {
14631 return Err(invalid_feed("entry SHA-256 does not match"));
14632 }
14633 Ok(())
14634}
14635
14636#[derive(Serialize)]
14642struct UnsignedRotation<'a> {
14643 v: u8,
14644 op: &'a str,
14645 brain: &'a str,
14646 public_key: &'a str,
14647 new_brain: &'a str,
14648 new_public_key: &'a str,
14649 prior_head_seq: u64,
14650 prior_feed_hash: Option<&'a str>,
14651 ts: String,
14652}
14653
14654#[derive(Debug, Deserialize, Serialize)]
14659#[serde(deny_unknown_fields)]
14660struct RotationJournal {
14661 v: u8,
14662 origin: String,
14663 brain: String,
14664 old_brain: String,
14665 new_brain: String,
14666 prior_head_seq: u64,
14667 prior_feed_hash: Option<String>,
14668 statement: String,
14669}
14670
14671fn rotation_journal_path(key_path: &Path) -> PathBuf {
14672 let mut path = key_path.as_os_str().to_os_string();
14673 path.push(".rotation.json");
14674 PathBuf::from(path)
14675}
14676
14677fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
14678 #[cfg(unix)]
14679 let file = {
14680 use std::os::fd::{AsRawFd as _, FromRawFd as _};
14681 use std::os::unix::ffi::OsStrExt as _;
14682 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
14683 .map_err(|error| {
14684 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
14685 })?;
14686 let leaf_name = path
14687 .file_name()
14688 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
14689 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
14690 let fd = unsafe {
14691 libc::openat(
14692 parent.as_raw_fd(),
14693 leaf.as_ptr(),
14694 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14695 )
14696 };
14697 if fd < 0 {
14698 return Err(bad_agent_key(
14699 "the rotation journal must be an existing regular file without symlink ancestors",
14700 ));
14701 }
14702 unsafe { std::fs::File::from_raw_fd(fd) }
14703 };
14704 #[cfg(not(unix))]
14705 let file = std::fs::File::open(path)
14706 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
14707 let metadata = file
14708 .metadata()
14709 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
14710 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
14711 return Err(bad_agent_key(
14712 "the rotation journal must be a bounded regular file",
14713 ));
14714 }
14715 #[cfg(unix)]
14716 {
14717 use std::os::unix::fs::PermissionsExt as _;
14718 if metadata.permissions().mode() & 0o077 != 0 {
14719 return Err(bad_agent_key(
14720 "the rotation journal is accessible to group/other; set mode 0600",
14721 ));
14722 }
14723 }
14724 serde_json::from_reader(file)
14725 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
14726}
14727
14728fn remove_rotation_journal(path: &Path) {
14729 #[cfg(unix)]
14730 {
14731 use std::os::fd::AsRawFd as _;
14732 use std::os::unix::ffi::OsStrExt as _;
14733 let Ok(parent) =
14734 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
14735 else {
14736 return;
14737 };
14738 let Some(leaf_name) = path.file_name() else {
14739 return;
14740 };
14741 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
14742 return;
14743 };
14744 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
14745 let _ = parent.sync_all();
14746 }
14747 }
14748 #[cfg(not(unix))]
14749 {
14750 let _ = std::fs::remove_file(path);
14751 }
14752}
14753
14754fn validate_rotation_journal(
14755 journal: &RotationJournal,
14756 cfg: &HubConfig,
14757 canonical_brain: &str,
14758 old_key: &AgentSigningKey,
14759 new_key: &AgentSigningKey,
14760 head: &Head,
14761) -> LinkResult<()> {
14762 if journal.v != 1
14763 || journal.origin != normalized_origin(&cfg.hub)?
14764 || journal.brain != canonical_brain
14765 || journal.old_brain != old_key.multikey
14766 || journal.new_brain != new_key.multikey
14767 || journal.prior_head_seq != head.seq
14768 || journal.prior_feed_hash != head.feed_hash
14769 {
14770 return Err(invalid_feed(
14771 "rotation journal does not match the verified key and feed boundary",
14772 ));
14773 }
14774 let statement: RotationStatement = serde_json::from_str(&journal.statement)
14775 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
14776 if statement.prior_head_seq != journal.prior_head_seq
14777 || statement.prior_feed_hash != journal.prior_feed_hash
14778 || statement.brain != old_key.multikey
14779 || statement.public_key != old_key.public_key_spki
14780 || statement.new_brain != new_key.multikey
14781 || statement.new_public_key != new_key.public_key_spki
14782 {
14783 return Err(invalid_feed(
14784 "rotation journal statement does not match its durable intent",
14785 ));
14786 }
14787 let identity = FeedIdentity {
14788 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
14789 public_key_spki: new_key.public_key_spki.clone(),
14790 previous: vec![PreviousIdentity {
14791 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
14792 public_key_spki: old_key.public_key_spki.clone(),
14793 }],
14794 rotations: vec![journal.statement.clone()],
14795 };
14796 verify_identity_chain(&identity, None)?;
14797 Ok(())
14798}
14799
14800#[derive(Debug, Serialize)]
14802pub struct RotationReport {
14803 pub brain: String,
14805 pub multikey: String,
14807 #[serde(rename = "keyFile")]
14809 pub key_file: String,
14810 pub previous: Vec<String>,
14812}
14813
14814pub fn rotate_brain_key(
14820 cfg: &HubConfig,
14821 brain: &str,
14822 old_key: &AgentSigningKey,
14823 out: &Path,
14824) -> LinkResult<RotationReport> {
14825 require_hardened_filesystem("key rotation")?;
14826 require_safe_ref(brain)?;
14827 let new_key = if out.exists() {
14831 load_signing_key(out)?
14832 } else {
14833 let rng = ring::rand::SystemRandom::new();
14834 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
14835 .map_err(|_| bad_agent_key("key generation failed"))?;
14836 let pair = agent_keypair(pkcs8.as_ref())?;
14837 let (public_key_spki, multikey) = public_identity_for(&pair);
14838 write_secret_new(
14839 out,
14840 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
14841 )?;
14842 AgentSigningKey {
14843 pkcs8: pkcs8.as_ref().to_vec(),
14844 multikey,
14845 public_key_spki,
14846 }
14847 };
14848 let new_spki = new_key.public_key_spki.clone();
14849 let new_multikey = new_key.multikey.clone();
14850 let journal_path = rotation_journal_path(out);
14851 let before_v2 = v2_verified_head(cfg, brain)?;
14852 let (canonical_brain, served_identity, observed_head, v2_profile) =
14853 if let Some(head) = before_v2 {
14854 let observed = Head {
14855 brain: head.brain_id.clone(),
14856 seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14857 updated_at: head
14858 .pointer
14859 .as_ref()
14860 .map(|pointer| pointer.signed_at.clone()),
14861 feed_hash: head
14862 .pointer
14863 .as_ref()
14864 .map(|pointer| pointer.feed_hash.clone()),
14865 verified: true,
14866 };
14867 let identity = v2_identity(&head.identity);
14868 let canonical = head.brain_id.clone();
14869 accept_v2_head(cfg, &head)?;
14870 (canonical, identity, observed, true)
14871 } else {
14872 let remote = verified_remote_head(cfg, brain, false)?;
14873 let identity = remote
14874 .identity
14875 .clone()
14876 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
14877 (remote.head.brain.clone(), identity, remote.head, false)
14878 };
14879 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
14880 let already_rotated = served_multikey == new_multikey;
14881 if already_rotated && !journal_path.exists() {
14886 remove_rotation_journal(&journal_path);
14887 return Ok(RotationReport {
14888 brain: brain.to_string(),
14889 multikey: new_multikey,
14890 key_file: out.display().to_string(),
14891 previous: served_identity
14892 .previous
14893 .iter()
14894 .map(|identity| format!("ed25519:{}", identity.fingerprint))
14895 .collect(),
14896 });
14897 }
14898 if !already_rotated && served_multikey != old_key.multikey {
14899 return Err(invalid_feed(
14900 "the supplied old key is not the brain's verified current identity",
14901 ));
14902 }
14903
14904 let journal = if journal_path.exists() {
14905 read_rotation_journal(&journal_path)?
14906 } else {
14907 let ts = crate::now()
14908 .with_timezone(&chrono::Utc)
14909 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
14910 .to_string();
14911 let unsigned = serde_json::to_string(&UnsignedRotation {
14912 v: 1,
14913 op: "rotate",
14914 brain: &old_key.multikey,
14915 public_key: &old_key.public_key_spki,
14916 new_brain: &new_multikey,
14917 new_public_key: &new_spki,
14918 prior_head_seq: observed_head.seq,
14919 prior_feed_hash: observed_head.feed_hash.as_deref(),
14920 ts,
14921 })
14922 .expect("serialize rotation");
14923 let old_pair = agent_keypair(&old_key.pkcs8)?;
14924 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14925 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
14926 let journal = RotationJournal {
14927 v: 1,
14928 origin: normalized_origin(&cfg.hub)?,
14929 brain: canonical_brain.clone(),
14930 old_brain: old_key.multikey.clone(),
14931 new_brain: new_multikey.clone(),
14932 prior_head_seq: observed_head.seq,
14933 prior_feed_hash: observed_head.feed_hash.clone(),
14934 statement,
14935 };
14936 let mut exact = serde_json::to_vec(&journal)
14937 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
14938 exact.push(b'\n');
14939 if write_secret_new(&journal_path, &exact).is_err() {
14940 read_rotation_journal(&journal_path)?
14943 } else {
14944 journal
14945 }
14946 };
14947 validate_rotation_journal(
14948 &journal,
14949 cfg,
14950 &canonical_brain,
14951 old_key,
14952 &new_key,
14953 &observed_head,
14954 )?;
14955
14956 let body = json!({ "statement": journal.statement });
14957 let path = format!("/api/hub/brains/{brain}/rotate");
14958 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
14959 let attempted_failure = match attempted {
14960 Ok(response) if (200..300).contains(&response.status) => None,
14961 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
14962 Err(error) => Some(error),
14963 };
14964
14965 let identity = if v2_profile {
14969 match v2_verified_head(cfg, brain) {
14970 Ok(Some(after)) => {
14971 let identity = v2_identity(&after.identity);
14972 accept_v2_head(cfg, &after)?;
14973 identity
14974 }
14975 Ok(None) => {
14976 return Err(attempted_failure.unwrap_or_else(|| {
14977 invalid_feed("rotated v2 brain no longer serves a v2 head")
14978 }));
14979 }
14980 Err(error) => return Err(attempted_failure.unwrap_or(error)),
14981 }
14982 } else {
14983 match verified_remote_head(cfg, brain, false) {
14984 Ok(after) => after
14985 .identity
14986 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
14987 Err(error) => return Err(attempted_failure.unwrap_or(error)),
14988 }
14989 };
14990 if format!("ed25519:{}", identity.fingerprint) != new_multikey
14991 || identity.public_key_spki != new_spki
14992 {
14993 return Err(attempted_failure.unwrap_or_else(|| {
14994 invalid_feed("hub acknowledged rotation without committing the verified new identity")
14995 }));
14996 }
14997 if v2_profile {
14998 if let Some(error) = attempted_failure {
14999 return Err(error);
15004 }
15005 }
15006 let previous = identity
15007 .previous
15008 .iter()
15009 .map(|prior| format!("ed25519:{}", prior.fingerprint))
15010 .collect();
15011 remove_rotation_journal(&journal_path);
15012
15013 Ok(RotationReport {
15014 brain: brain.to_string(),
15015 multikey: new_multikey,
15016 key_file: out.display().to_string(),
15017 previous,
15018 })
15019}
15020
15021#[derive(Debug, Serialize)]
15027pub struct MirrorReport {
15028 pub brain: String,
15030 #[serde(rename = "headSeq")]
15032 pub head_seq: u64,
15033 #[serde(rename = "feedHash")]
15035 pub feed_hash: Option<String>,
15036 pub entries: u64,
15038 pub pinned: String,
15040 pub files: usize,
15042}
15043
15044pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
15046
15047#[derive(Debug)]
15049pub struct VerifiedMirrorMaterial {
15050 pub brain: String,
15051 pub head_seq: u64,
15052 pub feed_hash: Option<String>,
15053 pub identity: serde_json::Value,
15054 pub entries: Vec<(u64, String, String)>,
15056 pub pack_sha256: Option<String>,
15057}
15058
15059#[derive(Deserialize)]
15060#[serde(deny_unknown_fields)]
15061struct StoredMirrorHead {
15062 brain: String,
15063 #[serde(rename = "headSeq")]
15064 head_seq: u64,
15065 #[serde(rename = "feedHash")]
15066 feed_hash: Option<String>,
15067}
15068
15069pub fn verify_mirror_material(
15072 head_bytes: &[u8],
15073 identity_bytes: &[u8],
15074 feed_bytes: &[Vec<u8>],
15075 snapshot_pack: Option<&[u8]>,
15076 expected_anchor: &str,
15077) -> LinkResult<VerifiedMirrorMaterial> {
15078 let snapshot_hash = snapshot_pack
15079 .filter(|pack| !pack.is_empty())
15080 .map(content_sha256);
15081 verify_mirror_material_with_pack_hash(
15082 head_bytes,
15083 identity_bytes,
15084 feed_bytes,
15085 snapshot_hash.as_deref(),
15086 expected_anchor,
15087 )
15088}
15089
15090pub fn verify_mirror_material_with_pack_hash(
15094 head_bytes: &[u8],
15095 identity_bytes: &[u8],
15096 feed_bytes: &[Vec<u8>],
15097 snapshot_pack_sha256: Option<&str>,
15098 expected_anchor: &str,
15099) -> LinkResult<VerifiedMirrorMaterial> {
15100 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
15101 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
15102 require_safe_ref(&head.brain)?;
15103 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
15104 return Err(invalid_feed(
15105 "stored mirror feed count does not match its bounded head sequence",
15106 ));
15107 }
15108 let aggregate = feed_bytes
15109 .iter()
15110 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
15111 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
15112 if aggregate > MAX_FEED_REPLAY_BYTES {
15113 return Err(invalid_feed(
15114 "stored mirror feed metadata exceeds the aggregate limit",
15115 ));
15116 }
15117 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
15118 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
15119 let anchor = verify_identity_chain(&identity, None)?;
15120 if anchor != expected_anchor {
15121 return Err(invalid_feed(
15122 "stored mirror identity does not descend from the explicitly trusted anchor",
15123 ));
15124 }
15125
15126 let mut entries = Vec::with_capacity(feed_bytes.len());
15127 let mut items = Vec::with_capacity(feed_bytes.len());
15128 let mut previous_hash = None;
15129 let mut pack_sha256 = None;
15130 for (index, bytes) in feed_bytes.iter().enumerate() {
15131 let exact = bytes
15132 .strip_suffix(b"\n")
15133 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
15134 if exact.ends_with(b"\n") {
15135 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
15136 }
15137 let entry: FeedEntry = serde_json::from_slice(exact)
15138 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
15139 let expected_seq = index as u64 + 1;
15140 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
15141 return Err(invalid_feed(
15142 "stored mirror feed is not contiguous and hash-chained",
15143 ));
15144 }
15145 let canonical = serde_json::to_vec(&entry)
15146 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
15147 if canonical != exact {
15148 return Err(invalid_feed(
15149 "stored feed entry is not in normative serialization",
15150 ));
15151 }
15152 let hash = content_sha256(bytes);
15153 let item = FeedItem {
15154 hash: hash.clone(),
15155 entry,
15156 };
15157 verify_feed_item(&item, &identity)?;
15158 previous_hash = Some(hash.clone());
15159 if expected_seq == head.head_seq {
15160 pack_sha256 = Some(item.entry.pack_sha256.clone());
15161 }
15162 entries.push((
15163 expected_seq,
15164 std::str::from_utf8(exact)
15165 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
15166 .to_string(),
15167 hash,
15168 ));
15169 items.push(item);
15170 }
15171 if previous_hash != head.feed_hash {
15172 return Err(invalid_feed(
15173 "stored mirror feed does not converge on its advertised head",
15174 ));
15175 }
15176 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
15177 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
15178 (0, None, None) => {}
15179 (_, Some(actual), Some(expected)) if actual == expected => {}
15180 _ => {
15181 return Err(LinkError::InvalidPack {
15182 message: "stored snapshot pack does not match the signed head digest".to_string(),
15183 });
15184 }
15185 }
15186 let identity_value = serde_json::to_value(&identity)
15187 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
15188 Ok(VerifiedMirrorMaterial {
15189 brain: head.brain,
15190 head_seq: head.head_seq,
15191 feed_hash: head.feed_hash,
15192 identity: identity_value,
15193 entries,
15194 pack_sha256,
15195 })
15196}
15197
15198pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
15201 format!(
15202 "{:x}",
15203 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
15204 )
15205}
15206
15207pub fn content_sha256(bytes: &[u8]) -> String {
15210 format!("{:x}", Sha256::digest(bytes))
15211}
15212
15213pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
15215 let mut digest = Sha256::new();
15216 let mut buffer = [0u8; 64 * 1024];
15217 loop {
15218 let read = reader.read(&mut buffer)?;
15219 if read == 0 {
15220 break;
15221 }
15222 digest.update(&buffer[..read]);
15223 }
15224 Ok(format!("{:x}", digest.finalize()))
15225}
15226
15227#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
15235pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
15236 require_hardened_filesystem("mirror")?;
15237 require_safe_ref(brain)?;
15238 #[cfg(windows)]
15239 {
15240 let _ = (cfg, dest);
15241 return Err(LinkError::UnsupportedPlatform {
15242 operation: "atomic whole-mirror replacement on Windows",
15243 });
15244 }
15245 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
15246 let name = dest
15247 .file_name()
15248 .and_then(|name| name.to_str())
15249 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
15250 .ok_or_else(|| LinkError::UnsafePath {
15251 path: dest.display().to_string(),
15252 })?;
15253 #[cfg(unix)]
15254 let parent_dir = open_or_create_dir_nofollow(parent)?;
15255 #[cfg(unix)]
15256 use std::os::fd::AsRawFd as _;
15257 #[cfg(unix)]
15258 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
15259 #[cfg(unix)]
15260 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
15261 None => false,
15262 Some(true) => true,
15263 Some(false) => {
15264 return Err(LinkError::UnsafePath {
15265 path: dest.display().to_string(),
15266 });
15267 }
15268 };
15269
15270 #[cfg(unix)]
15273 let legacy_backup_name = c_name(
15274 format!(".{name}.dbmd-backup").as_bytes(),
15275 &dest.display().to_string(),
15276 )?;
15277 #[cfg(unix)]
15278 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
15279 return Err(LinkError::UnsafePath {
15280 path: parent
15281 .join(format!(".{name}.dbmd-backup"))
15282 .display()
15283 .to_string(),
15284 });
15285 }
15286
15287 let nonce = std::time::SystemTime::now()
15288 .duration_since(std::time::UNIX_EPOCH)
15289 .unwrap_or_default()
15290 .as_nanos();
15291 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
15292 #[cfg(unix)]
15293 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
15294 #[cfg(unix)]
15295 let stage_dir = create_dir_exclusive_at(
15296 parent_dir.as_raw_fd(),
15297 &stage_name,
15298 &dest.display().to_string(),
15299 )?;
15300
15301 let assembled = (|| -> LinkResult<MirrorReport> {
15302 let remote = verified_remote_head(cfg, brain, true)?;
15303 let brain_id = remote.head.brain.clone();
15304 let identity = remote
15305 .identity
15306 .as_ref()
15307 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
15308 let anchor = remote
15309 .anchor
15310 .clone()
15311 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
15312 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
15313 let snapshot_entries = parse_store_pack(pack.clone())?;
15314 let snapshot_count = snapshot_entries.len();
15315 let mut staged_entries = snapshot_entries;
15316 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
15317 for item in &remote.entries {
15318 let mut exact = serde_json::to_vec(&item.entry)
15319 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
15320 exact.push(b'\n');
15321 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
15322 return Err(invalid_feed(
15323 "serialized mirror entry differs from its verified hash",
15324 ));
15325 }
15326 staged_entries.push((
15327 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
15328 exact,
15329 ));
15330 }
15331 let mut identity_bytes = serde_json::to_vec(identity)
15332 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
15333 identity_bytes.push(b'\n');
15334 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
15335 let mut head_bytes = serde_json::to_vec(&json!({
15336 "brain": brain_id,
15337 "headSeq": remote.head.seq,
15338 "feedHash": remote.head.feed_hash,
15339 }))
15340 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
15341 head_bytes.push(b'\n');
15342 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
15343 staged_entries.push((
15344 CONFIG_REL_PATH.to_string(),
15345 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
15346 ));
15347 #[cfg(unix)]
15348 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
15349
15350 Ok(MirrorReport {
15351 brain: brain_id,
15352 head_seq: remote.head.seq,
15353 feed_hash: remote.head.feed_hash,
15354 entries: remote.entries.len() as u64,
15355 pinned: anchor,
15356 files: snapshot_count,
15357 })
15358 })();
15359
15360 let report = match assembled {
15361 Ok(report) => report,
15362 Err(error) => {
15363 #[cfg(unix)]
15364 let _ = remove_tree_at(
15365 parent_dir.as_raw_fd(),
15366 &stage_name,
15367 &dest.display().to_string(),
15368 );
15369 return Err(error);
15370 }
15371 };
15372
15373 #[cfg(unix)]
15374 if let Err(error) =
15375 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
15376 {
15377 let _ = remove_tree_at(
15378 parent_dir.as_raw_fd(),
15379 &stage_name,
15380 &dest.display().to_string(),
15381 );
15382 return Err(error);
15383 }
15384 #[cfg(unix)]
15387 if dest_exists {
15388 remove_tree_at(
15389 parent_dir.as_raw_fd(),
15390 &stage_name,
15391 &dest.display().to_string(),
15392 )?;
15393 }
15394 #[cfg(unix)]
15395 parent_dir.sync_all()?;
15396 Ok(report)
15397}
15398
15399fn verified_remote_head(
15400 cfg: &HubConfig,
15401 brain: &str,
15402 require_full_chain: bool,
15403) -> LinkResult<VerifiedRemote> {
15404 require_hardened_filesystem("verified link.md state")?;
15405 require_safe_ref(brain)?;
15406 let trust_directory = open_trust_dir(cfg)?;
15410 let path = format!("/api/hub/brains/{brain}");
15411 let body = ensure_ok(
15412 request(cfg, "GET", &path, None, Auth::Required)?,
15413 "subscribe",
15414 )?;
15415 let resolved_brain = body
15416 .get("id")
15417 .and_then(Value::as_str)
15418 .filter(|id| crate::ulid::is_ulid(id))
15419 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
15420 .to_string();
15421 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
15422 return Err(invalid_feed(
15423 "brain card id differs from the explicitly requested brain id",
15424 ));
15425 }
15426 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
15431 let (pinned, alias_binding) =
15432 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
15433 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
15434 let advertised_hash = body
15435 .get("feedHash")
15436 .and_then(Value::as_str)
15437 .map(str::to_string);
15438 let updated_at = body
15439 .get("updatedAt")
15440 .and_then(Value::as_str)
15441 .map(str::to_string);
15442 if let Some(pin) = &pinned {
15443 if seq < pin.head_seq {
15444 return Err(invalid_feed(format!(
15445 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
15446 pin.head_seq
15447 )));
15448 }
15449 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
15450 return Err(invalid_feed(
15451 "feed equivocation: the checkpoint sequence now has a different hash",
15452 ));
15453 }
15454 }
15455 if seq == 0 {
15456 if advertised_hash.is_some() {
15457 return Err(invalid_feed("an empty feed advertised a head hash"));
15458 }
15459 let identity: FeedIdentity = serde_json::from_value(
15460 body.get("identity")
15461 .cloned()
15462 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
15463 )
15464 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
15465 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
15466 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
15471 save_canonical_pin_and_alias(
15472 cfg,
15473 &trust_directory,
15474 brain,
15475 &resolved_brain,
15476 TrustState {
15477 v: 2,
15478 origin: normalized_origin(&cfg.hub)?,
15479 requested: resolved_brain.clone(),
15480 brain: resolved_brain.clone(),
15481 home: None,
15482 anchor: anchor.clone(),
15483 current: format!("ed25519:{}", identity.fingerprint),
15484 head_seq: 0,
15485 feed_hash: None,
15486 rotations: identity.rotations.clone(),
15487 hub_signer: None,
15488 protocol_profile: None,
15489 },
15490 alias_binding.as_ref(),
15491 )?;
15492 return Ok(VerifiedRemote {
15493 head: Head {
15494 brain: resolved_brain,
15495 seq,
15496 updated_at,
15497 feed_hash: None,
15498 verified: true,
15499 },
15500 identity: Some(identity),
15501 head_entry: None,
15502 entries: Vec::new(),
15503 anchor: Some(anchor),
15504 });
15505 }
15506 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
15507 return Err(invalid_feed(
15508 "non-empty feed did not advertise a valid SHA-256 head",
15509 ));
15510 }
15511
15512 let replay_head_only = !require_full_chain
15516 && pinned
15517 .as_ref()
15518 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
15519 let mut after = if replay_head_only {
15520 seq - 1
15521 } else if require_full_chain || pinned.is_none() {
15522 0
15523 } else {
15524 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
15525 };
15526 let mut expected_seq = after + 1;
15527 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
15528 None
15529 } else {
15530 pinned
15531 .as_ref()
15532 .and_then(|checkpoint| checkpoint.feed_hash.clone())
15533 };
15534 let mut identity: Option<FeedIdentity> = None;
15535 let mut anchor: Option<String> = None;
15536 let mut head_entry: Option<FeedItem> = None;
15537 let mut all_entries = Vec::new();
15538 let mut observed_entries = Vec::new();
15539 let replay_count = seq
15540 .checked_sub(after)
15541 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
15542 if replay_count > MAX_FEED_REPLAY_ENTRIES {
15543 return Err(invalid_feed(format!(
15544 "feed replay requires {replay_count} entries, over the client cap"
15545 )));
15546 }
15547 let mut replay_bytes = 0u64;
15548
15549 loop {
15550 let feed_bytes = ensure_raw_ok(
15551 request_raw(
15552 cfg,
15553 "GET",
15554 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
15555 None,
15556 Auth::Required,
15557 MAX_FEED_RESPONSE_BYTES,
15558 )?,
15559 "subscribe feed",
15560 )?;
15561 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
15562 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
15563 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
15564 return Err(invalid_feed("brain card and feed head disagree"));
15565 }
15566 if feed.entries.len() > FEED_PAGE_LIMIT {
15567 return Err(invalid_feed("feed page exceeds the requested entry limit"));
15568 }
15569 if feed.scope_limited {
15570 if require_full_chain {
15571 return Err(invalid_feed(
15572 "path-scoped grants cannot verify a full snapshot chain",
15573 ));
15574 }
15575 return Ok(VerifiedRemote {
15576 head: Head {
15577 brain: resolved_brain,
15578 seq,
15579 updated_at,
15580 feed_hash: advertised_hash,
15581 verified: false,
15582 },
15583 identity: None,
15584 head_entry: None,
15585 entries: Vec::new(),
15586 anchor: None,
15587 });
15588 }
15589 let page_identity = feed
15590 .identity
15591 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
15592 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
15593 if identity
15594 .as_ref()
15595 .is_some_and(|existing| existing != &page_identity)
15596 {
15597 return Err(invalid_feed("identity changed while reading the feed"));
15598 }
15599 if anchor
15600 .as_ref()
15601 .is_some_and(|existing| existing != &page_anchor)
15602 {
15603 return Err(invalid_feed(
15604 "identity anchor changed while reading the feed",
15605 ));
15606 }
15607 identity = Some(page_identity.clone());
15608 if anchor.is_none() {
15609 anchor = Some(page_anchor);
15610 }
15611 if feed.entries.is_empty() {
15612 return Err(invalid_feed("feed page was empty before the signed head"));
15613 }
15614
15615 for item in feed.entries {
15616 if item.entry.seq != expected_seq {
15617 return Err(invalid_feed(format!(
15618 "expected entry {expected_seq}, feed served {}",
15619 item.entry.seq
15620 )));
15621 }
15622 if item.entry.seq > seq {
15623 return Err(invalid_feed("feed advanced past the card snapshot"));
15624 }
15625 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
15626 return Err(invalid_feed(format!(
15627 "entry {} does not chain to the local checkpoint",
15628 item.entry.seq
15629 )));
15630 }
15631 verify_feed_item(&item, &page_identity)?;
15632 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
15633 replay_bytes = replay_bytes.saturating_add(
15634 serde_json::to_vec(&item)
15635 .map_err(|_| invalid_feed("could not size feed entry"))?
15636 .len() as u64,
15637 );
15638 if replay_bytes > MAX_FEED_REPLAY_BYTES {
15639 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
15640 }
15641 previous_hash = Some(item.hash.clone());
15642 after = item.entry.seq;
15643 expected_seq = expected_seq
15644 .checked_add(1)
15645 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
15646 if require_full_chain {
15647 all_entries.push(item.clone());
15648 }
15649 observed_entries.push(item.clone());
15650 head_entry = Some(item);
15651 }
15652 if after == seq {
15653 break;
15654 }
15655 }
15656
15657 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
15658 return Err(invalid_feed(
15659 "verified chain does not converge on the advertised head",
15660 ));
15661 }
15662 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
15663 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
15664 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
15665 save_canonical_pin_and_alias(
15666 cfg,
15667 &trust_directory,
15668 brain,
15669 &resolved_brain,
15670 TrustState {
15671 v: 2,
15672 origin: normalized_origin(&cfg.hub)?,
15673 requested: resolved_brain.clone(),
15674 brain: resolved_brain.clone(),
15675 home: None,
15676 anchor: anchor.clone(),
15677 current: format!("ed25519:{}", identity.fingerprint),
15678 head_seq: seq,
15679 feed_hash: advertised_hash.clone(),
15680 rotations: identity.rotations.clone(),
15681 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
15682 protocol_profile: pinned
15683 .as_ref()
15684 .and_then(|state| state.protocol_profile.clone()),
15685 },
15686 alias_binding.as_ref(),
15687 )?;
15688 Ok(VerifiedRemote {
15689 head: Head {
15690 brain: resolved_brain,
15691 seq,
15692 updated_at,
15693 feed_hash: advertised_hash,
15694 verified: true,
15695 },
15696 identity: Some(identity),
15697 head_entry,
15698 entries: all_entries,
15699 anchor: Some(anchor),
15700 })
15701}
15702
15703pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
15708 if let Some(verified) = v2_verified_head(cfg, brain)? {
15709 let observation = Head {
15710 brain: verified.brain_id.clone(),
15711 seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
15712 updated_at: verified
15713 .pointer
15714 .as_ref()
15715 .map(|pointer| pointer.signed_at.clone()),
15716 feed_hash: verified
15717 .pointer
15718 .as_ref()
15719 .map(|pointer| pointer.feed_hash.clone()),
15720 verified: true,
15721 };
15722 accept_v2_head(cfg, &verified)?;
15723 return Ok(observation);
15724 }
15725 Ok(verified_remote_head(cfg, brain, false)?.head)
15726}
15727
15728#[cfg(test)]
15729mod tests {
15730 use super::*;
15731
15732 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
15733
15734 fn drain_test_http_request(stream: &mut std::net::TcpStream) {
15735 use std::io::Read as _;
15736
15737 stream
15738 .set_read_timeout(Some(std::time::Duration::from_secs(2)))
15739 .unwrap();
15740 let mut request = Vec::new();
15741 let mut chunk = [0_u8; 4096];
15742 loop {
15743 let read = stream.read(&mut chunk).unwrap();
15744 assert!(read > 0, "test client closed before its request completed");
15745 request.extend_from_slice(&chunk[..read]);
15746 let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") else {
15747 continue;
15748 };
15749 let headers = String::from_utf8_lossy(&request[..header_end]);
15750 let content_length = headers
15751 .lines()
15752 .find_map(|line| {
15753 let (name, value) = line.split_once(':')?;
15754 name.eq_ignore_ascii_case("content-length")
15755 .then(|| value.trim().parse::<usize>().unwrap())
15756 })
15757 .unwrap_or(0);
15758 if request.len() >= header_end + 4 + content_length {
15759 return;
15760 }
15761 }
15762 }
15763
15764 fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
15765 json!({
15766 "sha256": "a".repeat(64),
15767 "bytes": 10,
15768 "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
15769 })
15770 }
15771
15772 #[test]
15773 fn upload_reservations_batch_by_count_and_by_size() {
15774 let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
15778 let batches = batch_upload_declarations(declarations.clone());
15779
15780 assert!(batches.len() > 1, "5,000 blobs must not ride one request");
15781 for batch in &batches {
15782 assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
15783 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15784 .expect("batch serializes")
15785 .len();
15786 assert!(
15787 bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
15788 "batch body {bytes} exceeds the reservation budget"
15789 );
15790 }
15791 let flattened: Vec<Value> = batches.into_iter().flatten().collect();
15792 assert_eq!(
15793 flattened, declarations,
15794 "batching must preserve the set and order"
15795 );
15796 }
15797
15798 #[test]
15799 fn only_load_shaped_hub_answers_are_worth_asking_again() {
15800 for status in [408, 429, 500, 502, 503, 504] {
15805 assert!(is_retryable_hub_status(status), "{status} is load-shaped");
15806 }
15807 for status in [400, 401, 403, 404, 409, 413, 422] {
15808 assert!(
15809 !is_retryable_hub_status(status),
15810 "{status} states something about the request"
15811 );
15812 }
15813 let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
15815 assert!(total >= 60_000, "backoff totals only {total}ms");
15816 }
15817
15818 #[test]
15819 fn a_batch_shares_a_connection_only_within_one_authority() {
15820 let cfg = HubConfig {
15825 hub: "https://www.sevrahq.com".to_string(),
15826 key: Some("k".to_string()),
15827 agent_key: None,
15828 brain_key: None,
15829 state_dir: PathBuf::from("."),
15830 store_selected: false,
15831 };
15832 assert!(shared_staging_agent(&cfg, &[]).is_none());
15833 assert!(
15834 shared_staging_agent(
15835 &cfg,
15836 &[
15837 "https://one.example.com/a?sig=1",
15838 "https://two.example.com/b?sig=2",
15839 ]
15840 )
15841 .is_none(),
15842 "two authorities must not share a pinned pool"
15843 );
15844 assert!(
15845 shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
15846 "an unsafe object-store URL must not produce an agent"
15847 );
15848 assert!(
15849 shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
15850 "credentials in the URL must not produce an agent"
15851 );
15852 }
15853
15854 #[test]
15855 fn a_staged_change_states_only_operations_and_blobs() {
15856 let operations = vec![json!({
15860 "op": "put",
15861 "path": "records/a.md",
15862 "blob": "a".repeat(64),
15863 "bytes": 3,
15864 })];
15865 let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
15866 let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
15867 let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
15868 let keys: Vec<&str> = parsed
15869 .as_object()
15870 .expect("manifest is an object")
15871 .keys()
15872 .map(String::as_str)
15873 .collect();
15874 assert_eq!(keys, ["blobs", "operations"]);
15875 assert_eq!(parsed["operations"], Value::Array(operations));
15876 assert_eq!(parsed["blobs"], blobs);
15877 }
15878
15879 #[test]
15880 fn a_staged_push_signs_the_change_not_the_transport() {
15881 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15886 let staged = json!({
15887 "mutation_id": "dbmd-1",
15888 "rebase": "strict",
15889 "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
15890 });
15891 let view = v2_signed_request_view(&staged, &operations);
15892 assert_eq!(view["operations"], Value::Array(operations.clone()));
15893 assert!(view.get("staged_change").is_none());
15894 assert_eq!(view["mutation_id"], staged["mutation_id"]);
15895
15896 let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
15897 assert_eq!(v2_signed_request_view(&inline, &[]), inline);
15898 }
15899
15900 #[test]
15901 fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
15902 let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
15903 let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
15904 .expect_err("an oversized change must not be staged");
15905 assert!(
15906 matches!(error, LinkError::PushTooLarge { .. }),
15907 "expected a size refusal, got {error:?}"
15908 );
15909 }
15910
15911 #[test]
15912 fn a_push_that_fits_the_request_is_left_inline() {
15913 let cfg = HubConfig {
15917 hub: "http://127.0.0.1:9".to_string(),
15918 key: Some("k".to_string()),
15919 agent_key: None,
15920 brain_key: None,
15921 state_dir: PathBuf::from("."),
15922 store_selected: false,
15923 };
15924 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15925 let mut body = json!({
15926 "mutation_id": "dbmd-1",
15927 "operations": operations,
15928 "blobs": [],
15929 });
15930 stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
15931 assert!(body.get("staged_change").is_none());
15932 assert_eq!(body["operations"], Value::Array(operations));
15933 }
15934
15935 #[test]
15936 fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
15937 let declarations: Vec<Value> = (0..2_000)
15941 .map(|index| {
15942 json!({
15943 "sha256": "a".repeat(64),
15944 "bytes": 10,
15945 "coordinates": (0..24)
15946 .map(|slot| format!(
15947 "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
15948 ))
15949 .collect::<Vec<_>>(),
15950 })
15951 })
15952 .collect();
15953 let batches = batch_upload_declarations(declarations);
15954 assert!(
15955 batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
15956 "wide coordinate sets must bound the batch by size"
15957 );
15958 for batch in &batches {
15959 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15960 .expect("batch serializes")
15961 .len();
15962 assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
15963 }
15964 }
15965
15966 #[test]
15967 fn a_small_push_still_rides_exactly_one_request() {
15968 let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
15969 assert_eq!(batch_upload_declarations(declarations).len(), 1);
15970 assert!(batch_upload_declarations(Vec::new()).is_empty());
15971 }
15972
15973 #[test]
15974 fn exact_source_move_becomes_one_provenance_preserving_rename() {
15975 let hash = "a".repeat(64);
15976 let operations = vec![
15977 json!({
15978 "op": "put",
15979 "path": "sources/curated/item.md",
15980 "expected": { "kind": "absent" },
15981 "blob": hash,
15982 "bytes": 19,
15983 }),
15984 json!({
15985 "op": "delete",
15986 "path": "sources/inbox/item.md",
15987 "expected": { "kind": "blob", "hash": hash },
15988 }),
15989 ];
15990
15991 assert_eq!(
15992 infer_exact_source_promotions(operations),
15993 vec![json!({
15994 "op": "rename",
15995 "from": "sources/inbox/item.md",
15996 "to": "sources/curated/item.md",
15997 "expected_from": { "kind": "blob", "hash": hash },
15998 "expected_to": { "kind": "absent" },
15999 "blob": hash,
16000 "bytes": 19,
16001 })]
16002 );
16003 }
16004
16005 #[test]
16006 fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
16007 let hash = "b".repeat(64);
16008 let operations = vec![
16009 json!({
16010 "op": "delete",
16011 "path": "sources/inbox/a.md",
16012 "expected": { "kind": "blob", "hash": hash },
16013 }),
16014 json!({
16015 "op": "delete",
16016 "path": "sources/inbox/b.md",
16017 "expected": { "kind": "blob", "hash": hash },
16018 }),
16019 json!({
16020 "op": "put",
16021 "path": "sources/curated/item.md",
16022 "expected": { "kind": "absent" },
16023 "blob": hash,
16024 "bytes": 19,
16025 }),
16026 ];
16027
16028 assert_eq!(
16029 infer_exact_source_promotions(operations.clone()),
16030 operations,
16031 "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
16032 );
16033 }
16034
16035 #[test]
16036 fn an_exact_dual_plane_source_move_remains_a_provenance_preserving_rename() {
16037 let hash = "c".repeat(64);
16038 let operations = vec![
16039 json!({
16040 "op": "delete",
16041 "path": "sources/inbox/item.md",
16042 "expected": { "kind": "blob", "hash": hash },
16043 }),
16044 json!({
16045 "op": "put_asset_content",
16046 "path": "sources/archive/item.md",
16047 "expected": { "kind": "absent" },
16048 "blob": hash,
16049 "bytes": 19,
16050 }),
16051 ];
16052 assert_eq!(
16053 infer_exact_source_promotions(operations),
16054 vec![json!({
16055 "op": "rename",
16056 "from": "sources/inbox/item.md",
16057 "to": "sources/archive/item.md",
16058 "expected_from": { "kind": "blob", "hash": hash },
16059 "expected_to": { "kind": "absent" },
16060 "blob": hash,
16061 "bytes": 19,
16062 })]
16063 );
16064 }
16065
16066 #[test]
16067 fn accepted_source_promotion_advances_the_local_baseline_exactly() {
16068 let hash = "c".repeat(64);
16069 let mut candidate = std::collections::BTreeMap::from([(
16070 "sources/inbox/item.md".to_string(),
16071 V2BaselineFile {
16072 sha256: hash.clone(),
16073 bytes: 19,
16074 proof: None,
16075 },
16076 )]);
16077 let mut candidate_assets = std::collections::BTreeMap::new();
16078 let operations = vec![
16079 json!({
16080 "op": "rename",
16081 "from": "sources/inbox/item.md",
16082 "to": "sources/curated/item.md",
16083 "expected_from": { "kind": "blob", "hash": hash },
16084 "expected_to": { "kind": "absent" },
16085 "blob": hash,
16086 "bytes": 19,
16087 }),
16088 json!({
16089 "op": "put",
16090 "path": "records/rsvps/item.md",
16091 "expected": { "kind": "absent" },
16092 "blob": "d".repeat(64),
16093 "bytes": 23,
16094 }),
16095 ];
16096
16097 assert!(!apply_generated_v2_operations(
16098 &operations,
16099 &std::collections::BTreeMap::new(),
16100 &mut candidate,
16101 &mut candidate_assets,
16102 )
16103 .unwrap());
16104 assert!(!candidate.contains_key("sources/inbox/item.md"));
16105 assert_eq!(
16106 candidate
16107 .get("sources/curated/item.md")
16108 .map(|file| (&file.sha256, file.bytes)),
16109 Some((&hash, 19))
16110 );
16111 assert_eq!(
16112 candidate
16113 .get("records/rsvps/item.md")
16114 .map(|file| (file.sha256.as_str(), file.bytes)),
16115 Some((
16116 "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
16117 23
16118 ))
16119 );
16120 }
16121
16122 fn merge_fixture(
16123 base: Option<&str>,
16124 remote: Option<&str>,
16125 local: Option<&str>,
16126 keep_local: bool,
16127 ) -> V2PulledMerge<String> {
16128 let map = |value: Option<&str>| {
16129 value
16130 .map(|value| [("records/a.md".to_string(), value.to_string())])
16131 .into_iter()
16132 .flatten()
16133 .collect::<std::collections::BTreeMap<_, _>>()
16134 };
16135 merge_v2_pulled_records(
16136 &map(base),
16137 &map(remote),
16138 &map(local),
16139 |value, _| value.clone(),
16140 |value, _| value.clone(),
16141 |_| keep_local,
16142 )
16143 }
16144
16145 #[test]
16146 fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
16147 let path = "records/a.md".to_string();
16148
16149 let local_add = merge_fixture(None, None, Some("local"), false);
16150 assert_eq!(
16151 local_add.records.get(&path).map(String::as_str),
16152 Some("local")
16153 );
16154 assert!(local_add.accept_remote.is_empty());
16155 assert!(local_add.conflicts.is_empty());
16156
16157 let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
16158 assert_eq!(
16159 local_edit.records.get(&path).map(String::as_str),
16160 Some("local")
16161 );
16162 assert!(local_edit.accept_remote.is_empty());
16163 assert!(local_edit.conflicts.is_empty());
16164
16165 let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
16166 assert!(!local_delete.records.contains_key(&path));
16167 assert!(local_delete.accept_remote.is_empty());
16168 assert!(local_delete.conflicts.is_empty());
16169
16170 let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
16171 assert_eq!(
16172 remote_edit.records.get(&path).map(String::as_str),
16173 Some("remote")
16174 );
16175 assert!(remote_edit.accept_remote.contains(&path));
16176 assert!(remote_edit.conflicts.is_empty());
16177
16178 let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
16179 assert!(!remote_delete.records.contains_key(&path));
16180 assert!(remote_delete.accept_remote.contains(&path));
16181 assert!(remote_delete.conflicts.is_empty());
16182
16183 let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
16184 assert_eq!(
16185 same_edit.records.get(&path).map(String::as_str),
16186 Some("same")
16187 );
16188 assert!(same_edit.accept_remote.contains(&path));
16189 assert!(same_edit.conflicts.is_empty());
16190
16191 let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
16192 assert_eq!(conflict.conflicts, vec![path.clone()]);
16193 assert_eq!(
16194 conflict.records.get(&path).map(String::as_str),
16195 Some("local")
16196 );
16197 assert!(conflict.accept_remote.is_empty());
16198
16199 let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
16200 assert_eq!(
16201 kept_home.records.get(&path).map(String::as_str),
16202 Some("local")
16203 );
16204 assert!(kept_home.accept_remote.is_empty());
16205 assert!(kept_home.conflicts.is_empty());
16206 }
16207
16208 #[test]
16209 fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
16210 let path = "sources/report.pdf";
16211 let record = crate::AssetRecord {
16212 path: path.to_string(),
16213 sha256: "a".repeat(64),
16214 bytes: 42,
16215 media_type: "application/pdf".to_string(),
16216 wrappers: vec!["gzip".to_string()],
16217 required: true,
16218 };
16219 let mut remote = V2BaselineAsset {
16220 blob_sha256: record.sha256.clone(),
16221 bytes: record.bytes,
16222 media_type: record.media_type.clone(),
16223 wrappers: record.wrappers.clone(),
16224 required: record.required,
16225 disposition: "withheld".to_string(),
16226 leaf_hash: "b".repeat(64),
16227 };
16228
16229 assert!(v2_asset_resumes_hosting(
16230 Some(&remote),
16231 path,
16232 &record,
16233 "hosted"
16234 ));
16235 assert!(!v2_asset_resumes_hosting(
16236 Some(&remote),
16237 path,
16238 &record,
16239 "withheld"
16240 ));
16241
16242 remote.disposition = "hosted".to_string();
16243 assert!(!v2_asset_resumes_hosting(
16244 Some(&remote),
16245 path,
16246 &record,
16247 "hosted"
16248 ));
16249
16250 remote.disposition = "withheld".to_string();
16251 remote.blob_sha256 = "c".repeat(64);
16252 assert!(!v2_asset_resumes_hosting(
16253 Some(&remote),
16254 path,
16255 &record,
16256 "hosted"
16257 ));
16258 assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
16259 }
16260
16261 #[test]
16262 fn v2_fresh_clone_preserves_only_exact_inherited_withheld_asset_absence() {
16263 let path = "sources/report.pdf";
16264 let record = crate::AssetRecord {
16265 path: path.to_string(),
16266 sha256: "a".repeat(64),
16267 bytes: 42,
16268 media_type: "application/pdf".to_string(),
16269 wrappers: vec!["records/report.md".to_string()],
16270 required: true,
16271 };
16272 let mut base = V2BaselineAsset {
16273 blob_sha256: record.sha256.clone(),
16274 bytes: record.bytes,
16275 media_type: record.media_type.clone(),
16276 wrappers: record.wrappers.clone(),
16277 required: record.required,
16278 disposition: "withheld".to_string(),
16279 leaf_hash: "b".repeat(64),
16280 };
16281
16282 assert!(v2_asset_inherits_withheld_absence(
16283 Some(&base),
16284 Some(&record),
16285 Some(&record),
16286 false,
16287 ));
16288 assert!(!v2_asset_inherits_withheld_absence(
16289 Some(&base),
16290 Some(&record),
16291 Some(&record),
16292 true,
16293 ));
16294
16295 base.disposition = "hosted".to_string();
16296 assert!(!v2_asset_inherits_withheld_absence(
16297 Some(&base),
16298 Some(&record),
16299 Some(&record),
16300 false,
16301 ));
16302
16303 base.disposition = "withheld".to_string();
16304 let mut changed = record.clone();
16305 changed.bytes += 1;
16306 assert!(!v2_asset_inherits_withheld_absence(
16307 Some(&base),
16308 Some(&record),
16309 Some(&changed),
16310 false,
16311 ));
16312 assert!(!v2_asset_inherits_withheld_absence(
16313 None,
16314 None,
16315 Some(&record),
16316 false,
16317 ));
16318 }
16319
16320 #[test]
16321 fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
16322 let path = "records/team/alpha.md".to_string();
16323 let deleted_path = "records/team/deleted.md".to_string();
16324 let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
16325 sha256,
16326 bytes,
16327 file: None,
16328 };
16329 let files = vec![
16330 V2ConflictFile {
16331 path: path.clone(),
16332 base: coordinate(None, None),
16333 local: coordinate(Some("b".repeat(64)), Some(7)),
16334 remote: coordinate(Some("a".repeat(64)), Some(5)),
16335 },
16336 V2ConflictFile {
16337 path: deleted_path.clone(),
16338 base: coordinate(Some("c".repeat(64)), Some(9)),
16339 local: coordinate(Some("d".repeat(64)), Some(11)),
16340 remote: coordinate(None, None),
16341 },
16342 ];
16343 let proven = V2BaselineFile {
16344 sha256: "a".repeat(64),
16345 bytes: 5,
16346 proof: None,
16347 };
16348 let current = [(path.clone(), proven.clone())]
16349 .into_iter()
16350 .collect::<std::collections::BTreeMap<_, _>>();
16351
16352 let (selected, deleted) = v2_take_remote_selection(&files, ¤t).unwrap();
16353 assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
16354 assert_eq!(deleted, vec![deleted_path.clone()]);
16355
16356 let changed = [(
16357 path.clone(),
16358 V2BaselineFile {
16359 sha256: "e".repeat(64),
16360 bytes: 5,
16361 proof: None,
16362 },
16363 )]
16364 .into_iter()
16365 .collect::<std::collections::BTreeMap<_, _>>();
16366 assert!(v2_take_remote_selection(&files, &changed).is_err());
16367
16368 let resurrected = [
16369 (path, proven),
16370 (
16371 deleted_path,
16372 V2BaselineFile {
16373 sha256: "f".repeat(64),
16374 bytes: 13,
16375 proof: None,
16376 },
16377 ),
16378 ]
16379 .into_iter()
16380 .collect::<std::collections::BTreeMap<_, _>>();
16381 assert!(v2_take_remote_selection(&files, &resurrected).is_err());
16382 }
16383
16384 #[cfg(target_os = "linux")]
16385 #[test]
16386 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
16387 use std::os::fd::AsRawFd as _;
16388
16389 let sandbox = tempfile::TempDir::new().unwrap();
16390 let parent = std::fs::File::open(sandbox.path()).unwrap();
16391 let stage = std::ffi::CString::new("stage").unwrap();
16392 let destination = std::ffi::CString::new("brain").unwrap();
16393
16394 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
16395 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
16396 install_stage_at(
16397 parent.as_raw_fd(),
16398 stage.as_c_str(),
16399 destination.as_c_str(),
16400 false,
16401 )
16402 .unwrap();
16403 assert!(!sandbox.path().join("stage").exists());
16404 assert_eq!(
16405 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
16406 b"created"
16407 );
16408
16409 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
16410 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
16411 install_stage_at(
16412 parent.as_raw_fd(),
16413 stage.as_c_str(),
16414 destination.as_c_str(),
16415 true,
16416 )
16417 .unwrap();
16418 assert_eq!(
16419 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
16420 b"replacement"
16421 );
16422 assert_eq!(
16423 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
16424 b"created",
16425 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
16426 );
16427 }
16428
16429 struct SignedRemoteFixture {
16430 card: String,
16431 feed: String,
16432 key: AgentSigningKey,
16433 identity: FeedIdentity,
16434 }
16435
16436 fn signed_remote_fixture() -> SignedRemoteFixture {
16437 let rng = ring::rand::SystemRandom::new();
16438 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16439 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16440 let (public_key, multikey) = public_identity_for(&pair);
16441 let identity = FeedIdentity {
16442 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16443 public_key_spki: public_key.clone(),
16444 previous: Vec::new(),
16445 rotations: Vec::new(),
16446 };
16447 let mut entry = FeedEntry {
16448 v: 1,
16449 seq: 1,
16450 ts: "2026-07-30T12:00:00.000Z".to_string(),
16451 brain: multikey.clone(),
16452 public_key: public_key.clone(),
16453 kind: "push".to_string(),
16454 op: "snapshot".to_string(),
16455 pack_sha256: "a".repeat(64),
16456 files: Vec::new(),
16457 removed: Vec::new(),
16458 prev_entry_hash: None,
16459 sig: String::new(),
16460 };
16461 let unsigned = UnsignedFeedEntry {
16462 v: entry.v,
16463 seq: entry.seq,
16464 ts: &entry.ts,
16465 brain: &entry.brain,
16466 public_key: &entry.public_key,
16467 kind: &entry.kind,
16468 op: &entry.op,
16469 pack_sha256: &entry.pack_sha256,
16470 files: &entry.files,
16471 removed: &entry.removed,
16472 prev_entry_hash: &entry.prev_entry_hash,
16473 };
16474 entry.sig =
16475 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16476 let mut exact = serde_json::to_vec(&entry).unwrap();
16477 exact.push(b'\n');
16478 let hash = content_sha256(&exact);
16479 let card = json!({
16480 "id": TEST_BRAIN_ID,
16481 "headSeq": 1,
16482 "feedHash": hash,
16483 "identity": identity.clone(),
16484 })
16485 .to_string();
16486 let feed = json!({
16487 "headSeq": 1,
16488 "feedHash": hash,
16489 "identity": identity.clone(),
16490 "entries": [{"hash": hash, "entry": entry}],
16491 "scopeLimited": false,
16492 })
16493 .to_string();
16494 SignedRemoteFixture {
16495 card,
16496 feed,
16497 key: AgentSigningKey {
16498 pkcs8: pkcs8.as_ref().to_vec(),
16499 multikey,
16500 public_key_spki: public_key,
16501 },
16502 identity,
16503 }
16504 }
16505
16506 #[test]
16507 fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
16508 let file = |path: &str, byte: char| FeedFile {
16509 path: path.to_string(),
16510 sha256: byte.to_string().repeat(64),
16511 bytes: 1,
16512 };
16513 let a0 = file("records/a.md", 'a');
16514 let a1 = file("records/a.md", 'b');
16515 let stable = file("records/stable.md", 'c');
16516 let added = file("records/added.md", 'd');
16517 let removed_file = file("records/removed.md", 'e');
16518 let previous = vec![a0, stable.clone(), removed_file.clone()];
16519 let resulting = vec![a1.clone(), stable.clone(), added.clone()];
16520 let removed = vec![removed_file.path.clone()];
16521
16522 assert_eq!(
16523 verify_v1_manifest_disclosure(
16524 "edit",
16525 &previous,
16526 &resulting,
16527 &[a1.clone(), added.clone()],
16528 &removed,
16529 ),
16530 Ok(())
16531 );
16532 assert_eq!(
16533 verify_v1_manifest_disclosure(
16534 "edit",
16535 &previous,
16536 &resulting,
16537 &[stable.clone(), added.clone(), a1.clone()],
16538 &removed,
16539 ),
16540 Ok(())
16541 );
16542 assert_eq!(
16543 verify_v1_manifest_disclosure(
16544 "edit",
16545 &previous,
16546 &resulting,
16547 std::slice::from_ref(&added),
16548 &removed,
16549 ),
16550 Err(V1DisclosureError::EditMissingChange)
16551 );
16552 assert_eq!(
16553 verify_v1_manifest_disclosure(
16554 "edit",
16555 &previous,
16556 &resulting,
16557 &[file("records/a.md", 'f'), added.clone()],
16558 &removed,
16559 ),
16560 Err(V1DisclosureError::EditFalseFile)
16561 );
16562 assert_eq!(
16563 verify_v1_manifest_disclosure(
16564 "edit",
16565 &previous,
16566 &resulting,
16567 &[a1.clone(), added.clone()],
16568 &[],
16569 ),
16570 Err(V1DisclosureError::RemovedMismatch)
16571 );
16572 assert_eq!(
16573 verify_v1_manifest_disclosure(
16574 "push",
16575 &previous,
16576 &resulting,
16577 &[added.clone(), stable, a1],
16578 &removed,
16579 ),
16580 Ok(())
16581 );
16582 assert_eq!(
16583 verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
16584 Err(V1DisclosureError::PushManifestMismatch)
16585 );
16586 }
16587
16588 #[test]
16589 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
16590 let fixture = signed_remote_fixture();
16591 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
16592 let item = feed["entries"][0].to_string();
16593 let oversized_page = format!(
16594 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
16595 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
16596 .collect::<Vec<_>>()
16597 .join(",")
16598 );
16599 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
16600
16601 let oversized_identity = format!(
16602 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
16603 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
16604 .collect::<Vec<_>>()
16605 .join(",")
16606 );
16607 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
16608
16609 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
16610 let oversized_entry = format!(
16611 "{{\"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\"}}",
16612 "a".repeat(64),
16613 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
16614 .collect::<Vec<_>>()
16615 .join(",")
16616 );
16617 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
16618 }
16619
16620 #[test]
16621 fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
16622 let id = "01arz3ndektsv4rrffq69g5fav";
16623 let digest = "a".repeat(64);
16624 assert_eq!(
16625 V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
16626 V2BulkConfirmation {
16627 id: id.to_string(),
16628 digest,
16629 }
16630 );
16631 for invalid in [
16632 "",
16633 "01arz3ndektsv4rrffq69g5fav",
16634 "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
16635 "01arz3ndektsv4rrffq69g5fav:ABCDEF",
16636 "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
16637 ] {
16638 assert!(matches!(
16639 V2BulkConfirmation::parse(invalid),
16640 Err(LinkError::InvalidPack { .. })
16641 ));
16642 }
16643 }
16644
16645 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
16646 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
16647 use std::net::TcpListener;
16648
16649 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16650 let url = format!("http://{}", listener.local_addr().unwrap());
16651 let handle = std::thread::spawn(move || {
16652 for (status, body) in responses {
16653 let (stream, _) = listener.accept().unwrap();
16654 let mut reader = BufReader::new(stream);
16655 let mut line = String::new();
16656 reader.read_line(&mut line).unwrap();
16657 let mut content_length = 0usize;
16658 loop {
16659 line.clear();
16660 reader.read_line(&mut line).unwrap();
16661 if line == "\r\n" || line == "\n" || line.is_empty() {
16662 break;
16663 }
16664 if let Some((name, value)) = line.split_once(':') {
16665 if name.eq_ignore_ascii_case("content-length") {
16666 content_length = value.trim().parse().unwrap();
16667 }
16668 }
16669 }
16670 let mut request_body = vec![0_u8; content_length];
16671 reader.read_exact(&mut request_body).unwrap();
16672 let response = format!(
16673 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
16674 body.len()
16675 );
16676 reader.get_mut().write_all(response.as_bytes()).unwrap();
16677 }
16678 });
16679 (url, handle)
16680 }
16681
16682 fn routed_json_hub(
16683 requests: usize,
16684 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
16685 ) -> (String, std::thread::JoinHandle<()>) {
16686 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
16687 use std::net::TcpListener;
16688
16689 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16690 let url = format!("http://{}", listener.local_addr().unwrap());
16691 let handle = std::thread::spawn(move || {
16692 for _ in 0..requests {
16693 let (stream, _) = listener.accept().unwrap();
16694 let mut reader = BufReader::new(stream);
16695 let mut line = String::new();
16696 reader.read_line(&mut line).unwrap();
16697 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
16698 let mut content_length = 0usize;
16699 loop {
16700 line.clear();
16701 reader.read_line(&mut line).unwrap();
16702 if line == "\r\n" || line == "\n" || line.is_empty() {
16703 break;
16704 }
16705 if let Some((name, value)) = line.split_once(':') {
16706 if name.eq_ignore_ascii_case("content-length") {
16707 content_length = value.trim().parse().unwrap();
16708 }
16709 }
16710 }
16711 let mut request_body = vec![0_u8; content_length];
16712 reader.read_exact(&mut request_body).unwrap();
16713 let (status, body) = respond(&path);
16714 let response = format!(
16715 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
16716 body.len()
16717 );
16718 reader.get_mut().write_all(response.as_bytes()).unwrap();
16719 }
16720 });
16721 (url, handle)
16722 }
16723
16724 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
16725 HubConfig {
16726 hub,
16727 key: Some("test-key".to_string()),
16728 agent_key: None,
16729 brain_key: None,
16730 state_dir,
16731 store_selected: false,
16732 }
16733 }
16734
16735 #[cfg(any(unix, windows))]
16736 #[test]
16737 fn an_expired_asset_capability_is_refreshed_without_losing_cached_progress() {
16738 use std::sync::{Arc, Mutex};
16739
16740 let bytes = b"immutable asset bytes".to_vec();
16741 let sha256 = content_sha256(&bytes);
16742 let commit_hash = "c".repeat(64);
16743 let base_url = Arc::new(Mutex::new(String::new()));
16744 let server_base = Arc::clone(&base_url);
16745 let object_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16746 let server_attempt = Arc::clone(&object_attempt);
16747 let response_bytes = bytes.clone();
16748 let response_sha = sha256.clone();
16749 let response_commit = commit_hash.clone();
16750 let (hub, server) = routed_json_hub(4, move |path| {
16751 if path.contains("/v2/assets/downloads") {
16752 let attempt = server_attempt.load(std::sync::atomic::Ordering::SeqCst) + 1;
16753 let url = format!("{}/asset/{attempt}", server_base.lock().unwrap());
16754 return (
16755 200,
16756 json!({
16757 "v": 2,
16758 "commit": response_commit,
16759 "downloads": [{
16760 "path": "assets/proof.bin",
16761 "sha256": response_sha,
16762 "bytes": response_bytes.len(),
16763 "url": url,
16764 "method": "GET"
16765 }]
16766 })
16767 .to_string(),
16768 );
16769 }
16770 let attempt = server_attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16771 if attempt == 0 {
16772 (403, "{}".to_string())
16773 } else {
16774 (200, String::from_utf8(response_bytes.clone()).unwrap())
16775 }
16776 });
16777 *base_url.lock().unwrap() = hub.clone();
16778
16779 let temp = tempfile::tempdir().unwrap();
16780 let cache = temp.path().join("cache");
16781 std::fs::create_dir(&cache).unwrap();
16782 let cfg = test_hub_config(hub, temp.path().to_path_buf());
16783 let pointer = V2PointerBody {
16784 v: 2,
16785 brain: TEST_BRAIN_ID.to_string(),
16786 seq: 1,
16787 commit_hash,
16788 feed_hash: "f".repeat(64),
16789 content_root: Some("a".repeat(64)),
16790 asset_root: Some("b".repeat(64)),
16791 materializer: "m".repeat(64),
16792 signer_epoch: 1,
16793 control_revision: "d".repeat(64),
16794 backup_preparation: "ready".to_string(),
16795 prior_pointer_hash: None,
16796 signed_at: "2026-08-23T00:00:00Z".to_string(),
16797 };
16798 let path = "assets/proof.bin".to_string();
16799 let asset = V2BaselineAsset {
16800 blob_sha256: sha256.clone(),
16801 bytes: bytes.len() as u64,
16802 media_type: "application/octet-stream".to_string(),
16803 wrappers: Vec::new(),
16804 required: true,
16805 disposition: "hosted".to_string(),
16806 leaf_hash: "e".repeat(64),
16807 };
16808
16809 let staged = stage_v2_asset_download_window(
16810 &cfg,
16811 TEST_BRAIN_ID,
16812 &pointer,
16813 &cache,
16814 &[(&path, &asset)],
16815 )
16816 .expect("a fresh authority-checked capability recovers an expired one");
16817 assert_eq!(staged.len(), 1);
16818 assert_eq!(staged[0].path, path);
16819 assert_eq!(std::fs::read(cache.join(sha256)).unwrap(), bytes);
16820 assert_eq!(object_attempt.load(std::sync::atomic::Ordering::SeqCst), 2);
16821 server.join().unwrap();
16822 }
16823
16824 #[cfg(any(unix, windows))]
16825 #[test]
16826 fn asset_capability_windows_cannot_exceed_the_worker_bound() {
16827 let temp = tempfile::tempdir().unwrap();
16828 let cfg = test_hub_config("http://127.0.0.1:1".to_string(), temp.path().to_path_buf());
16829 let pointer = V2PointerBody {
16830 v: 2,
16831 brain: TEST_BRAIN_ID.to_string(),
16832 seq: 1,
16833 commit_hash: "c".repeat(64),
16834 feed_hash: "f".repeat(64),
16835 content_root: Some("a".repeat(64)),
16836 asset_root: Some("b".repeat(64)),
16837 materializer: "m".repeat(64),
16838 signer_epoch: 1,
16839 control_revision: "d".repeat(64),
16840 backup_preparation: "ready".to_string(),
16841 prior_pointer_hash: None,
16842 signed_at: "2026-08-23T00:00:00Z".to_string(),
16843 };
16844 let paths = (0..=V2_DOWNLOAD_CAPABILITY_FILES)
16845 .map(|index| format!("assets/{index}.bin"))
16846 .collect::<Vec<_>>();
16847 let assets = paths
16848 .iter()
16849 .map(|_| V2BaselineAsset {
16850 blob_sha256: "a".repeat(64),
16851 bytes: 1,
16852 media_type: "application/octet-stream".to_string(),
16853 wrappers: Vec::new(),
16854 required: true,
16855 disposition: "hosted".to_string(),
16856 leaf_hash: "b".repeat(64),
16857 })
16858 .collect::<Vec<_>>();
16859 let pending = paths.iter().zip(&assets).collect::<Vec<_>>();
16860
16861 let error =
16862 stage_v2_asset_download_window(&cfg, TEST_BRAIN_ID, &pointer, temp.path(), &pending)
16863 .expect_err("an oversized window must fail before any network request");
16864 assert!(matches!(error, LinkError::InvalidFeed { .. }));
16865 }
16866
16867 #[test]
16868 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
16869 use ring::signature::KeyPair as _;
16870
16871 let rng = ring::rand::SystemRandom::new();
16872 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16873 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16874 let (spki, multikey) = public_identity_for(&pair);
16875 let key = AgentSigningKey {
16876 pkcs8: pkcs8.as_ref().to_vec(),
16877 multikey,
16878 public_key_spki: spki,
16879 };
16880 let header = linkmd_sig_header(
16881 &key,
16882 "https://hub-a.example",
16883 "post",
16884 "/api/hub/brains/brain/push?mode=exact",
16885 Some("{\"ok\":true}"),
16886 )
16887 .unwrap();
16888 assert!(header.starts_with("LinkMD-Sig v2,"));
16889 let ts = header
16890 .split(",ts=")
16891 .nth(1)
16892 .unwrap()
16893 .split(',')
16894 .next()
16895 .unwrap();
16896 let signature = URL_SAFE_NO_PAD
16897 .decode(header.rsplit(",sig=").next().unwrap())
16898 .unwrap();
16899 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
16900 let accepted = format!(
16901 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
16902 );
16903 let replayed = format!(
16904 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
16905 );
16906 let public = pair.public_key().as_ref();
16907 assert!(UnparsedPublicKey::new(&ED25519, public)
16908 .verify(accepted.as_bytes(), &signature)
16909 .is_ok());
16910 assert!(
16911 UnparsedPublicKey::new(&ED25519, public)
16912 .verify(replayed.as_bytes(), &signature)
16913 .is_err(),
16914 "a proof captured at hub A must not authenticate at hub B"
16915 );
16916 }
16917
16918 #[test]
16919 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
16920 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16921 let card = json!({
16922 "id": other,
16923 "headSeq": 0,
16924 "identity": signed_remote_fixture().identity,
16925 })
16926 .to_string();
16927 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
16928 let state = tempfile::tempdir().unwrap();
16929 let cfg = test_hub_config(hub, state.path().to_path_buf());
16930 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16931 assert!(
16932 error.contains("differs from the explicitly requested"),
16933 "{error}"
16934 );
16935 server.join().unwrap();
16936 }
16937
16938 #[test]
16939 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
16940 let first = signed_remote_fixture().identity;
16941 let second = signed_remote_fixture().identity;
16942 let card = |identity: FeedIdentity| {
16943 json!({
16944 "id": TEST_BRAIN_ID,
16945 "headSeq": 0,
16946 "identity": identity,
16947 })
16948 .to_string()
16949 };
16950 let (hub, server) = scripted_json_hub(vec![
16951 (404, "{}".to_string()),
16952 (200, card(first)),
16953 (404, "{}".to_string()),
16954 (200, card(second)),
16955 ]);
16956 let state = tempfile::tempdir().unwrap();
16957 let cfg = test_hub_config(hub, state.path().to_path_buf());
16958 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16959 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16960 assert!(
16961 error.contains("pinned anchor") || error.contains("forked away"),
16962 "{error}"
16963 );
16964 server.join().unwrap();
16965 }
16966
16967 #[test]
16968 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
16969 let old = signed_remote_fixture();
16970 let new = signed_remote_fixture();
16971 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
16972 let unsigned = serde_json::to_string(&UnsignedRotation {
16973 v: 1,
16974 op: "rotate",
16975 brain: &old.key.multikey,
16976 public_key: &old.key.public_key_spki,
16977 new_brain: &new.key.multikey,
16978 new_public_key: &new.key.public_key_spki,
16979 prior_head_seq: 1,
16980 prior_feed_hash: Some(&"a".repeat(64)),
16981 ts: "2026-07-30T12:00:00.000Z".to_string(),
16982 })
16983 .unwrap();
16984 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
16985 let rotation = format!(
16986 "{},\"sig\":\"{}\"}}",
16987 &unsigned[..unsigned.len() - 1],
16988 signature
16989 );
16990 let identity = FeedIdentity {
16991 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
16992 public_key_spki: new.key.public_key_spki,
16993 previous: vec![PreviousIdentity {
16994 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
16995 public_key_spki: old.key.public_key_spki,
16996 }],
16997 rotations: vec![rotation],
16998 };
16999 let card = json!({
17000 "id": TEST_BRAIN_ID,
17001 "headSeq": 0,
17002 "feedHash": null,
17003 "identity": identity,
17004 })
17005 .to_string();
17006 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
17007 let state = tempfile::tempdir().unwrap();
17008 let cfg = test_hub_config(hub, state.path().to_path_buf());
17009 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
17010 assert!(
17011 error.contains("rotation claims a feed boundary beyond the advertised head"),
17012 "{error}"
17013 );
17014 assert!(
17015 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
17016 "an inconsistent empty-head identity must not become the TOFU checkpoint"
17017 );
17018 server.join().unwrap();
17019 }
17020
17021 #[test]
17022 fn trust_checkpoint_rejects_a_later_fork() {
17023 let fixture = signed_remote_fixture();
17024 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
17025 fork["feedHash"] = Value::String("b".repeat(64));
17026 let (hub, server) = scripted_json_hub(vec![
17027 (404, "{}".to_string()),
17028 (200, fixture.card),
17029 (200, fixture.feed),
17030 (404, "{}".to_string()),
17031 (200, fork.to_string()),
17032 ]);
17033 let state = tempfile::tempdir().unwrap();
17034 let cfg = test_hub_config(hub, state.path().to_path_buf());
17035 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
17036 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
17037 server.join().unwrap();
17038 }
17039
17040 #[test]
17041 fn alias_and_canonical_id_share_one_identity_checkpoint() {
17042 let trusted = signed_remote_fixture();
17043 let attacker = signed_remote_fixture();
17044 let (hub, server) = scripted_json_hub(vec![
17045 (404, "{}".to_string()),
17046 (200, trusted.card),
17047 (200, trusted.feed),
17048 (404, "{}".to_string()),
17049 (200, attacker.card),
17050 ]);
17051 let state = tempfile::tempdir().unwrap();
17052 let cfg = test_hub_config(hub, state.path().to_path_buf());
17053 assert!(head(&cfg, "trusted-slug").unwrap().verified);
17054 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
17055 assert!(
17056 error.contains("equivocation")
17057 || error.contains("pinned")
17058 || error.contains("identity"),
17059 "{error}"
17060 );
17061 server.join().unwrap();
17062 }
17063
17064 #[test]
17065 fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
17066 let state = tempfile::tempdir().unwrap();
17067 let cfg = test_hub_config(
17068 "https://hub.example".to_string(),
17069 state.path().to_path_buf(),
17070 );
17071 let directory = open_trust_dir(&cfg).unwrap();
17072 let old = TEST_BRAIN_ID;
17073 let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
17074 save_alias_in(
17075 &cfg,
17076 &directory,
17077 &AliasBinding {
17078 v: 1,
17079 origin: normalized_origin(&cfg.hub).unwrap(),
17080 requested: "company-brain".to_string(),
17081 brain: old.to_string(),
17082 home: Some("company-brain".to_string()),
17083 },
17084 )
17085 .unwrap();
17086
17087 let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
17088 assert!(matches!(
17089 error,
17090 LinkError::AliasRebindRequired {
17091 alias,
17092 from,
17093 to
17094 } if alias == "company-brain" && from == old && to == new
17095 ));
17096 let unchanged = load_alias_in(&cfg, &directory, "company-brain")
17097 .unwrap()
17098 .unwrap();
17099 assert_eq!(unchanged.brain, old);
17100 assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
17101 }
17102
17103 #[test]
17104 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
17105 let alpha = signed_remote_fixture();
17106 let beta = signed_remote_fixture();
17107 let alpha_card = alpha.card.clone();
17108 let alpha_feed = alpha.feed.clone();
17109 let beta_card = beta.card.clone();
17110 let beta_feed = beta.feed.clone();
17111 let (hub, server) = routed_json_hub(5, move |path| {
17112 if path.ends_with("/v2/head") {
17113 (404, "{}".to_string())
17114 } else if path.contains("/alpha/feed?") {
17115 (200, alpha_feed.clone())
17116 } else if path.contains("/beta/feed?") {
17117 (200, beta_feed.clone())
17118 } else if path.ends_with("/alpha") {
17119 (200, alpha_card.clone())
17120 } else if path.ends_with("/beta") {
17121 (200, beta_card.clone())
17122 } else {
17123 (500, r#"{"error":"unexpected path"}"#.to_string())
17124 }
17125 });
17126 let state = tempfile::tempdir().unwrap();
17127 let cfg = test_hub_config(hub, state.path().to_path_buf());
17128 let alpha_cfg = cfg.clone();
17129 let beta_cfg = cfg;
17130 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
17131 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
17132 let results = [first.join().unwrap(), second.join().unwrap()];
17133 assert_eq!(
17134 results.iter().filter(|result| result.is_ok()).count(),
17135 1,
17136 "only one alias identity may establish canonical TOFU: {results:?}"
17137 );
17138 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
17139 server.join().unwrap();
17140 }
17141
17142 #[cfg(unix)]
17143 #[test]
17144 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
17145 use std::os::unix::fs::symlink;
17146
17147 let fixture = signed_remote_fixture();
17148 let card = json!({
17149 "id": TEST_BRAIN_ID,
17150 "headSeq": 0,
17151 "feedHash": Value::Null,
17152 "identity": fixture.identity,
17153 })
17154 .to_string();
17155 let work = tempfile::tempdir().unwrap();
17156 let outside = tempfile::tempdir().unwrap();
17157 let state = work.path().join("state");
17158 let moved = work.path().join("state-held");
17159 let swap_state = state.clone();
17160 let swap_moved = moved.clone();
17161 let outside_path = outside.path().to_path_buf();
17162 let (hub, server) = routed_json_hub(1, move |_| {
17163 std::fs::rename(&swap_state, &swap_moved).unwrap();
17165 symlink(&outside_path, &swap_state).unwrap();
17166 (200, card.clone())
17167 });
17168 let cfg = test_hub_config(hub, state);
17169
17170 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
17171 assert_eq!(verified.head.seq, 0);
17172 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
17173 assert!(std::fs::read_dir(moved.join("trust"))
17174 .unwrap()
17175 .flatten()
17176 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
17177 server.join().unwrap();
17178 }
17179
17180 #[test]
17181 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
17182 let remote = signed_remote_fixture();
17183 let unrelated = signed_remote_fixture().key;
17184 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
17185 let state = tempfile::tempdir().unwrap();
17186 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
17187 cfg.brain_key = Some(unrelated);
17188 let error = sync_push(
17189 &cfg,
17190 TEST_BRAIN_ID,
17191 &[("DB.md".to_string(), "signed local content".to_string())],
17192 )
17193 .unwrap_err()
17194 .to_string();
17195 assert!(
17196 error.contains("not the verified current brain identity"),
17197 "{error}"
17198 );
17199 server.join().unwrap();
17200 }
17201
17202 #[test]
17203 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
17204 let remote = signed_remote_fixture();
17205 let new = signed_remote_fixture().key;
17206 let state = tempfile::tempdir().unwrap();
17207 let new_file = state.path().join("new.key");
17208 std::fs::write(
17209 &new_file,
17210 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
17211 )
17212 .unwrap();
17213 #[cfg(unix)]
17214 {
17215 use std::os::unix::fs::PermissionsExt as _;
17216 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
17217 }
17218 let forged = json!({
17219 "brain": TEST_BRAIN_ID,
17220 "identity": {
17221 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
17222 "publicKeySpki": new.public_key_spki,
17223 }
17224 })
17225 .to_string();
17226 let (hub, server) = scripted_json_hub(vec![
17227 (404, "{}".to_string()),
17228 (200, remote.card.clone()),
17229 (200, remote.feed.clone()),
17230 (200, forged),
17231 (200, remote.card),
17232 (200, remote.feed),
17233 ]);
17234 let cfg = test_hub_config(hub, state.path().to_path_buf());
17235 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
17236 .unwrap_err()
17237 .to_string();
17238 assert!(
17239 error.contains("without committing the verified new identity"),
17240 "{error}"
17241 );
17242 server.join().unwrap();
17243 }
17244
17245 #[test]
17246 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
17247 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
17248 let raw = format!(
17249 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
17250 );
17251 let pack = build_store_pack(&[
17252 (
17253 "DB.md".to_string(),
17254 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
17255 ),
17256 ("records/clients/truth.md".to_string(), raw.clone()),
17257 ])
17258 .unwrap();
17259 let by_id = resolve_from_verified_pack(
17260 "01j5qc3v9k4ym8rwbn2tqe6f7d",
17261 &AddressTarget::Id(record_id.to_string()),
17262 pack.clone(),
17263 )
17264 .unwrap();
17265 assert_eq!(by_id["document"]["summary"], "Signed truth");
17266 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
17267 assert_eq!(
17268 by_id["document"]["contentSha"],
17269 content_sha256(raw.as_bytes())
17270 );
17271
17272 let by_path = resolve_from_verified_pack(
17273 "01j5qc3v9k4ym8rwbn2tqe6f7d",
17274 &AddressTarget::Path("records/clients/truth.md".to_string()),
17275 pack,
17276 )
17277 .unwrap();
17278 assert_eq!(by_path["document"]["id"], record_id);
17279 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
17280
17281 let wrong_id = resolve_from_verified_record_bytes(
17282 TEST_BRAIN_ID,
17283 &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
17284 "records/clients/truth.md".to_string(),
17285 raw.as_bytes().to_vec(),
17286 )
17287 .unwrap_err()
17288 .to_string();
17289 assert!(wrong_id.contains("id differs"), "{wrong_id}");
17290
17291 let wrong_path = resolve_from_verified_record_bytes(
17292 TEST_BRAIN_ID,
17293 &AddressTarget::Path("records/clients/other.md".to_string()),
17294 "records/clients/truth.md".to_string(),
17295 raw.into_bytes(),
17296 )
17297 .unwrap_err()
17298 .to_string();
17299 assert!(wrong_path.contains("path differs"), "{wrong_path}");
17300 }
17301
17302 #[test]
17303 fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
17304 let path = "records/clients/truth.md";
17305 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
17306 let raw = format!(
17307 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
17308 );
17309 let sha256 = content_sha256(raw.as_bytes());
17310 let mut nonce = 0_u128;
17311 let tree = crate::linkmd_v2::build_content_tree(
17312 &[crate::linkmd_v2::ContentFile {
17313 path: path.to_string(),
17314 blob_hash: sha256.clone(),
17315 bytes: raw.len() as u64,
17316 }],
17317 None,
17318 &mut || {
17319 nonce += 1;
17320 format!("{nonce:032x}")
17321 },
17322 )
17323 .unwrap();
17324 let root = tree.root.clone().unwrap();
17325 let mut directory_root = root.clone();
17326 let mut proof = Vec::new();
17327 for component in path.split('/') {
17328 let inclusion =
17329 crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
17330 let child = match &inclusion {
17331 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
17332 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
17333 panic!("fixture path must have an inclusion proof")
17334 }
17335 };
17336 proof.push(json!({
17337 "directory_root": directory_root,
17338 "component": component,
17339 "proof": inclusion,
17340 }));
17341 directory_root = child;
17342 }
17343 let commit_hash = "c".repeat(64);
17344 let pointer = V2PointerBody {
17345 v: 2,
17346 brain: TEST_BRAIN_ID.to_string(),
17347 seq: 1,
17348 commit_hash: commit_hash.clone(),
17349 feed_hash: "f".repeat(64),
17350 content_root: Some(root.clone()),
17351 asset_root: None,
17352 materializer: "dbmd-projection-v1".to_string(),
17353 signer_epoch: 1,
17354 control_revision: "d".repeat(64),
17355 backup_preparation: "e".repeat(64),
17356 prior_pointer_hash: None,
17357 signed_at: "2026-08-21T12:00:00.000Z".to_string(),
17358 };
17359 let manifest = json!({
17360 "v": 2,
17361 "commit": commit_hash,
17362 "content_root": root,
17363 "files": [{
17364 "path": path,
17365 "sha256": sha256,
17366 "bytes": raw.len(),
17367 "proof": proof,
17368 }],
17369 "next_cursor": Value::Null,
17370 })
17371 .to_string();
17372
17373 let path_manifest = manifest.clone();
17374 let (hub, server) = routed_json_hub(1, move |request| {
17375 assert_eq!(
17376 request,
17377 format!(
17378 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
17379 "c".repeat(64)
17380 )
17381 );
17382 (200, path_manifest.clone())
17383 });
17384 let state = tempfile::tempdir().unwrap();
17385 let cfg = test_hub_config(hub, state.path().to_path_buf());
17386 let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
17387 .unwrap()
17388 .unwrap();
17389 assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
17390 assert!(by_path.proof.is_some());
17391 server.join().unwrap();
17392
17393 let (hub, server) = routed_json_hub(1, move |request| {
17394 assert_eq!(
17395 request,
17396 format!(
17397 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
17398 "c".repeat(64)
17399 )
17400 );
17401 (404, r#"{"error":"File not found"}"#.to_string())
17402 });
17403 let state = tempfile::tempdir().unwrap();
17404 let cfg = test_hub_config(hub, state.path().to_path_buf());
17405 assert!(
17406 v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
17407 .unwrap()
17408 .is_none()
17409 );
17410 server.join().unwrap();
17411
17412 let id_manifest = manifest;
17413 let (hub, server) = routed_json_hub(1, move |request| {
17414 assert_eq!(
17415 request,
17416 format!(
17417 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
17418 "c".repeat(64)
17419 )
17420 );
17421 (200, id_manifest.clone())
17422 });
17423 let state = tempfile::tempdir().unwrap();
17424 let cfg = test_hub_config(hub, state.path().to_path_buf());
17425 let (located_path, by_id) =
17426 v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
17427 assert_eq!(located_path, path);
17428 assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
17429 server.join().unwrap();
17430 }
17431
17432 #[test]
17433 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
17434 let unsorted = vec![
17435 ("records/a.md".to_string(), "alpha\n".to_string()),
17436 ("DB.md".to_string(), "# db\n".to_string()),
17437 ];
17438 let sorted = vec![
17439 ("DB.md".to_string(), "# db\n".to_string()),
17440 ("records/a.md".to_string(), "alpha\n".to_string()),
17441 ];
17442 let pack = build_store_pack(&unsorted).unwrap();
17443
17444 assert_eq!(pack.len(), 219);
17449 assert_eq!(
17450 content_sha256(&pack),
17451 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
17452 );
17453 assert_eq!(pack, build_store_pack(&sorted).unwrap());
17454 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
17455 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
17456 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
17457
17458 assert_eq!(
17459 parse_store_pack(pack).unwrap(),
17460 vec![
17461 ("DB.md".to_string(), b"# db\n".to_vec()),
17462 ("records/a.md".to_string(), b"alpha\n".to_vec()),
17463 ]
17464 );
17465 }
17466
17467 #[test]
17468 fn canonical_store_pack_validates_every_path_before_writing() {
17469 let duplicate = vec![
17470 ("DB.md".to_string(), "first".to_string()),
17471 ("DB.md".to_string(), "second".to_string()),
17472 ];
17473 assert!(build_store_pack(&duplicate)
17474 .unwrap_err()
17475 .to_string()
17476 .contains("duplicate path"));
17477 assert!(matches!(
17478 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
17479 Err(LinkError::UnsafePath { .. })
17480 ));
17481 }
17482
17483 #[test]
17484 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
17485 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
17486 let mut bytes = vec![0_u8];
17489 let zip64_offset = bytes.len() as u64;
17490 bytes.extend_from_slice(b"PK\x06\x06");
17491 bytes.extend_from_slice(&44_u64.to_le_bytes());
17492 bytes.extend_from_slice(&[0_u8; 12]);
17493 bytes.extend_from_slice(&COUNT.to_le_bytes());
17494 bytes.extend_from_slice(&COUNT.to_le_bytes());
17495 bytes.extend_from_slice(&1_u64.to_le_bytes());
17496 bytes.extend_from_slice(&0_u64.to_le_bytes());
17497 bytes.extend_from_slice(b"PK\x06\x07");
17498 bytes.extend_from_slice(&0_u32.to_le_bytes());
17499 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
17500 bytes.extend_from_slice(&1_u32.to_le_bytes());
17501 bytes.extend_from_slice(b"PK\x05\x06");
17502 bytes.extend_from_slice(&0_u16.to_le_bytes());
17503 bytes.extend_from_slice(&0_u16.to_le_bytes());
17504 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17505 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17506 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17507 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17508 bytes.extend_from_slice(&0_u16.to_le_bytes());
17509
17510 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
17511 .unwrap_err()
17512 .to_string();
17513 assert!(error.contains("invalid file count"), "{error}");
17514 }
17515
17516 #[test]
17517 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
17518 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
17519 let mut bytes = vec![0_u8];
17520 let zip64_offset = bytes.len() as u64;
17521 bytes.extend_from_slice(b"PK\x06\x06");
17522 bytes.extend_from_slice(&44_u64.to_le_bytes());
17523 bytes.extend_from_slice(&[0_u8; 12]);
17524 bytes.extend_from_slice(&COUNT.to_le_bytes());
17525 bytes.extend_from_slice(&COUNT.to_le_bytes());
17526 bytes.extend_from_slice(&1_u64.to_le_bytes());
17527 bytes.extend_from_slice(&0_u64.to_le_bytes());
17528 bytes.extend_from_slice(b"PK\x06\x07");
17529 bytes.extend_from_slice(&0_u32.to_le_bytes());
17530 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
17531 bytes.extend_from_slice(&1_u32.to_le_bytes());
17532 bytes.extend_from_slice(b"PK\x05\x06");
17533 bytes.extend_from_slice(&0_u16.to_le_bytes());
17534 bytes.extend_from_slice(&0_u16.to_le_bytes());
17535 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17536 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17537 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17538 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17539 bytes.extend_from_slice(&0_u16.to_le_bytes());
17540 let fake_eocd = bytes.len() as u32;
17544 bytes.extend_from_slice(b"PK\x05\x06");
17545 bytes.extend_from_slice(&0_u16.to_le_bytes());
17546 bytes.extend_from_slice(&0_u16.to_le_bytes());
17547 bytes.extend_from_slice(&1_u16.to_le_bytes());
17548 bytes.extend_from_slice(&1_u16.to_le_bytes());
17549 bytes.extend_from_slice(&0_u32.to_le_bytes());
17550 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
17551 bytes.extend_from_slice(&0_u16.to_le_bytes());
17552
17553 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
17554 .unwrap_err()
17555 .to_string();
17556 assert!(error.contains("central directory"), "{error}");
17557 }
17558
17559 #[test]
17560 fn strict_http_status_handling_rejects_redirects_without_panicking() {
17561 let error = ensure_ok(
17562 HubResponse {
17563 status: 302,
17564 body: Some(json!({"redirect": "/elsewhere"})),
17565 },
17566 "mutation",
17567 )
17568 .unwrap_err();
17569 assert!(matches!(error, LinkError::Http { status: 302, .. }));
17570
17571 let error = ensure_raw_ok(
17572 RawHubResponse {
17573 status: 302,
17574 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
17575 },
17576 "feed",
17577 )
17578 .unwrap_err();
17579 assert!(matches!(error, LinkError::Http { status: 302, .. }));
17580 }
17581
17582 #[cfg(unix)]
17583 #[test]
17584 fn collect_push_files_refuses_external_symlink_and_nested_store() {
17585 use std::os::unix::fs::symlink;
17586
17587 let root = tempfile::tempdir().unwrap();
17588 std::fs::write(
17589 root.path().join("DB.md"),
17590 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
17591 )
17592 .unwrap();
17593 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
17594
17595 let external = tempfile::tempdir().unwrap();
17596 let secret = external.path().join("secret.md");
17597 std::fs::write(&secret, "TOP SECRET").unwrap();
17598 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
17599
17600 let store = Store::open_strict(root.path()).unwrap();
17601 let err = collect_push_files(&store).unwrap_err().to_string();
17602 assert!(err.contains("cannot push"), "{err}");
17603 assert!(
17604 !err.contains("TOP SECRET"),
17605 "external bytes must never leak"
17606 );
17607
17608 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
17609 let nested = root.path().join("records/nested");
17610 std::fs::create_dir_all(&nested).unwrap();
17611 std::fs::write(
17612 nested.join("DB.md"),
17613 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
17614 )
17615 .unwrap();
17616 let err = collect_push_files(&store).unwrap_err().to_string();
17617 assert!(err.contains("nested db.md store"), "{err}");
17618 }
17619
17620 #[test]
17621 fn collect_push_files_carries_curator_history_but_not_derived_catalogs() {
17622 let root = tempfile::tempdir().unwrap();
17623 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
17624 std::fs::create_dir_all(root.path().join("log")).unwrap();
17625 std::fs::write(
17626 root.path().join("DB.md"),
17627 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
17628 )
17629 .unwrap();
17630 std::fs::write(root.path().join("index.md"), "derived root catalog\n").unwrap();
17631 std::fs::write(
17632 root.path().join("records/notes/index.md"),
17633 "derived type catalog\n",
17634 )
17635 .unwrap();
17636 std::fs::write(
17637 root.path().join("records/notes/owned.md"),
17638 "---\ntype: note\nsummary: owned\ncreated: 2026-08-26T00:00:00Z\nupdated: 2026-08-26T00:00:00Z\n---\n",
17639 )
17640 .unwrap();
17641 std::fs::write(
17642 root.path().join("log.md"),
17643 "---\ntype: log\n---\n\n# Curator log\n",
17644 )
17645 .unwrap();
17646 std::fs::write(
17647 root.path().join("log/2026-07.md"),
17648 "---\ntype: log\n---\n\n# Curator log — 2026-07\n",
17649 )
17650 .unwrap();
17651 std::fs::write(root.path().join("log/README.txt"), "not a log archive\n").unwrap();
17652
17653 let store = Store::open_strict(root.path()).unwrap();
17654 let paths: Vec<String> = collect_push_files(&store)
17655 .unwrap()
17656 .into_iter()
17657 .map(|(path, _)| path)
17658 .collect();
17659
17660 assert!(paths.contains(&"DB.md".to_string()));
17661 assert!(paths.contains(&"records/notes/owned.md".to_string()));
17662 assert!(paths.contains(&"log.md".to_string()));
17663 assert!(paths.contains(&"log/2026-07.md".to_string()));
17664 assert!(!paths.contains(&"index.md".to_string()));
17665 assert!(!paths.contains(&"records/notes/index.md".to_string()));
17666 assert!(!paths.contains(&"log/README.txt".to_string()));
17667 }
17668
17669 #[cfg(unix)]
17670 #[test]
17671 fn remote_push_uses_opened_root_after_path_replacement() {
17672 use std::os::unix::fs::symlink;
17673
17674 let sandbox = tempfile::tempdir().unwrap();
17675 let root = sandbox.path().join("store");
17676 std::fs::create_dir_all(root.join("records/notes")).unwrap();
17677 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17678 std::fs::write(
17679 root.join("records/notes/owned.md"),
17680 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
17681 )
17682 .unwrap();
17683 let store = Store::open_strict(&root).unwrap();
17684 let detached = sandbox.path().join("detached");
17685 std::fs::rename(&root, &detached).unwrap();
17686
17687 let replacement = sandbox.path().join("replacement");
17688 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
17689 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17690 std::fs::write(
17691 replacement.join("records/notes/secret.md"),
17692 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
17693 )
17694 .unwrap();
17695 symlink(&replacement, &root).unwrap();
17696
17697 let files = collect_push_files(&store).unwrap();
17698 let wire_text = files
17699 .iter()
17700 .map(|(path, content)| format!("{path}\n{content}"))
17701 .collect::<Vec<_>>()
17702 .join("\n");
17703 assert!(wire_text.contains("owned upload"));
17704 assert!(!wire_text.contains("replacement sentinel"));
17705 assert!(!wire_text.contains("records/notes/secret.md"));
17706
17707 let remote = signed_remote_fixture();
17708 let (hub, server) = scripted_json_hub(vec![
17709 (200, remote.card),
17710 (200, remote.feed),
17711 (200, json!({"ok": true}).to_string()),
17712 ]);
17713 let state = tempfile::tempdir().unwrap();
17714 let cfg = test_hub_config(hub, state.path().to_path_buf());
17715 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
17716 assert_eq!(pushed, json!({"ok": true}));
17717 server.join().unwrap();
17718 }
17719
17720 #[test]
17721 fn signed_feed_item_verifies_identity_hash_and_signature() {
17722 use ring::rand::SystemRandom;
17723 use ring::signature::{Ed25519KeyPair, KeyPair};
17724
17725 const PREFIX: &[u8] = &[
17726 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
17727 ];
17728 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
17729 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17730 let mut spki = PREFIX.to_vec();
17731 spki.extend_from_slice(pair.public_key().as_ref());
17732 let public_key = URL_SAFE_NO_PAD.encode(&spki);
17733 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
17734 let mut entry = FeedEntry {
17735 v: 1,
17736 seq: 1,
17737 ts: "2026-07-14T00:00:00.000Z".to_string(),
17738 brain: format!("ed25519:{fingerprint}"),
17739 public_key: public_key.clone(),
17740 kind: "push".to_string(),
17741 op: "snapshot".to_string(),
17742 pack_sha256: "a".repeat(64),
17743 files: vec![FeedFile {
17744 path: "DB.md".to_string(),
17745 sha256: "b".repeat(64),
17746 bytes: 3,
17747 }],
17748 removed: vec![],
17749 prev_entry_hash: None,
17750 sig: String::new(),
17751 };
17752 let unsigned = UnsignedFeedEntry {
17753 v: entry.v,
17754 seq: entry.seq,
17755 ts: &entry.ts,
17756 brain: &entry.brain,
17757 public_key: &entry.public_key,
17758 kind: &entry.kind,
17759 op: &entry.op,
17760 pack_sha256: &entry.pack_sha256,
17761 files: &entry.files,
17762 removed: &entry.removed,
17763 prev_entry_hash: &entry.prev_entry_hash,
17764 };
17765 entry.sig =
17766 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
17767 let mut exact = serde_json::to_vec(&entry).unwrap();
17768 exact.push(b'\n');
17769 let item = FeedItem {
17770 hash: format!("{:x}", Sha256::digest(&exact)),
17771 entry,
17772 };
17773 let identity = FeedIdentity {
17774 fingerprint,
17775 public_key_spki: public_key,
17776 previous: Vec::new(),
17777 rotations: Vec::new(),
17778 };
17779 assert!(verify_feed_item(&item, &identity).is_ok());
17780 let mut tampered = item;
17781 tampered.entry.pack_sha256 = "c".repeat(64);
17782 assert!(verify_feed_item(&tampered, &identity).is_err());
17783 }
17784
17785 #[test]
17786 fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
17787 let rng = ring::rand::SystemRandom::new();
17788 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17789 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17790 let (spki, multikey) = public_identity_for(&pair);
17791 let identity = V2HeadIdentity {
17792 custody: "self".to_string(),
17793 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
17794 public_key_spki: spki.clone(),
17795 previous: Vec::new(),
17796 rotations: Vec::new(),
17797 };
17798 let unsigned = json!({
17799 "actor_ref": "a".repeat(64),
17800 "asset_root": Value::Null,
17801 "brain": multikey,
17802 "changes_sha256": "b".repeat(64),
17803 "control_revision": "c".repeat(64),
17804 "materializer": "dbmd-projection-v1",
17805 "op": "changeset",
17806 "parent_asset_root": Value::Null,
17807 "parent_commit": Value::Null,
17808 "parent_root": Value::Null,
17809 "prev_entry_hash": Value::Null,
17810 "public_key": spki,
17811 "seq": 1,
17812 "signer_epoch": 1,
17813 "state_root": "d".repeat(64),
17814 "ts": "2026-08-19T12:00:00.000Z",
17815 "v": 2,
17816 "v1_bridge": {
17817 "feed_hash": "e".repeat(64),
17818 "head_seq": 7,
17819 "pack_sha256": "f".repeat(64),
17820 },
17821 });
17822 let sign_value = |value: Value| {
17823 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
17824 let mut object = value.as_object().unwrap().clone();
17825 object.insert(
17826 "sig".to_string(),
17827 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17828 );
17829 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
17830 };
17831 assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
17832
17833 let mut extra = unsigned.clone();
17834 extra
17835 .as_object_mut()
17836 .unwrap()
17837 .insert("future".to_string(), Value::Bool(true));
17838 assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
17839
17840 let mut missing = unsigned.clone();
17841 missing.as_object_mut().unwrap().remove("v1_bridge");
17842 assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
17843
17844 let mut invalid_bridge = unsigned;
17845 invalid_bridge.as_object_mut().unwrap().insert(
17846 "v1_bridge".to_string(),
17847 json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
17848 );
17849 assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
17850 }
17851
17852 #[test]
17853 fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
17854 let vector: Value = serde_json::from_str(include_str!(
17855 "../tests/vectors/linkmd-v2-commit-bridge.json"
17856 ))
17857 .unwrap();
17858 let identity_value = vector.get("identity").unwrap();
17859 let identity = V2HeadIdentity {
17860 custody: "self".to_string(),
17861 fingerprint: identity_value
17862 .get("fingerprint")
17863 .and_then(Value::as_str)
17864 .unwrap()
17865 .to_string(),
17866 public_key_spki: identity_value
17867 .get("public_key_spki")
17868 .and_then(Value::as_str)
17869 .unwrap()
17870 .to_string(),
17871 previous: Vec::new(),
17872 rotations: Vec::new(),
17873 };
17874 let private = URL_SAFE_NO_PAD
17875 .decode(
17876 identity_value
17877 .get("private_key_pkcs8")
17878 .and_then(Value::as_str)
17879 .unwrap(),
17880 )
17881 .unwrap();
17882 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
17883 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
17884 .unwrap();
17885 let base = vector.get("body").unwrap().as_object().unwrap();
17886
17887 for item in vector.get("valid").unwrap().as_array().unwrap() {
17888 let mut body = base.clone();
17889 body.insert(
17890 "v1_bridge".to_string(),
17891 item.get("v1_bridge").unwrap().clone(),
17892 );
17893 body.insert(
17894 "sig".to_string(),
17895 item.get("signature_base64url").unwrap().clone(),
17896 );
17897 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
17898 assert!(verified_v2_commit_object(&signed, &identity).is_ok());
17899 assert_eq!(
17900 crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
17901 item.get("commit_hash").and_then(Value::as_str).unwrap()
17902 );
17903 assert_eq!(
17904 format!("{:x}", Sha256::digest(&signed)),
17905 item.get("feed_hash").and_then(Value::as_str).unwrap()
17906 );
17907 }
17908
17909 for item in vector.get("invalid").unwrap().as_array().unwrap() {
17910 let mut body = base.clone();
17911 if let Some(remove) = item.get("remove").and_then(Value::as_array) {
17912 for field in remove {
17913 body.remove(field.as_str().unwrap());
17914 }
17915 }
17916 if let Some(set) = item.get("set").and_then(Value::as_object) {
17917 for (field, value) in set {
17918 body.insert(field.clone(), value.clone());
17919 }
17920 }
17921 let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
17922 body.insert(
17923 "sig".to_string(),
17924 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17925 );
17926 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
17927 assert!(
17928 verified_v2_commit_object(&signed, &identity).is_err(),
17929 "accepted invalid shared vector {}",
17930 item.get("reason").and_then(Value::as_str).unwrap()
17931 );
17932 }
17933 }
17934
17935 #[test]
17936 fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
17937 let vector: Value = serde_json::from_str(include_str!(
17938 "../tests/vectors/linkmd-v2-changeset-withheld.json"
17939 ))
17940 .unwrap();
17941 assert_eq!(
17942 vector.get("profile").and_then(Value::as_str),
17943 Some("link.md-v2-changeset-withheld")
17944 );
17945 let canonical =
17946 crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
17947 let expected = STANDARD
17948 .decode(
17949 vector
17950 .get("canonical_base64")
17951 .and_then(Value::as_str)
17952 .unwrap(),
17953 )
17954 .unwrap();
17955 assert_eq!(canonical, expected);
17956 assert_eq!(
17957 crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
17958 vector.get("domain_hash").and_then(Value::as_str).unwrap()
17959 );
17960 }
17961
17962 #[test]
17963 fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
17964 let remote = signed_remote_fixture();
17965 let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
17966 let legacy_item = legacy.entries.first().unwrap();
17967 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
17968 let body = json!({
17969 "actor_ref": "a".repeat(64),
17970 "asset_root": Value::Null,
17971 "brain": remote.key.multikey,
17972 "changes_sha256": "b".repeat(64),
17973 "control_revision": "c".repeat(64),
17974 "materializer": "dbmd-projection-v1",
17975 "op": "changeset",
17976 "parent_asset_root": Value::Null,
17977 "parent_commit": Value::Null,
17978 "parent_root": Value::Null,
17979 "prev_entry_hash": Value::Null,
17980 "public_key": remote.key.public_key_spki,
17981 "seq": 1,
17982 "signer_epoch": 1,
17983 "state_root": "d".repeat(64),
17984 "ts": "2026-08-19T12:00:00.000Z",
17985 "v": 2,
17986 "v1_bridge": {
17987 "feed_hash": legacy_item.hash,
17988 "head_seq": legacy_item.entry.seq,
17989 "pack_sha256": legacy_item.entry.pack_sha256,
17990 },
17991 });
17992 let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
17993 let mut signed = body.as_object().unwrap().clone();
17994 signed.insert(
17995 "sig".to_string(),
17996 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17997 );
17998 let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
17999 let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
18000 let feed_hash = content_sha256(&raw);
18001 let pointer = V2PointerBody {
18002 v: 2,
18003 brain: TEST_BRAIN_ID.to_string(),
18004 seq: 1,
18005 commit_hash: commit_hash.clone(),
18006 feed_hash: feed_hash.clone(),
18007 content_root: Some("d".repeat(64)),
18008 asset_root: None,
18009 materializer: "dbmd-projection-v1".to_string(),
18010 signer_epoch: 1,
18011 control_revision: "c".repeat(64),
18012 backup_preparation: "e".repeat(64),
18013 prior_pointer_hash: None,
18014 signed_at: "2026-08-19T12:00:00.000Z".to_string(),
18015 };
18016 let v2_page = json!({
18017 "v": 2,
18018 "head_seq": 1,
18019 "head_commit_hash": commit_hash,
18020 "head_feed_hash": feed_hash,
18021 "entries": [{
18022 "seq": 1,
18023 "commit_hash": pointer.commit_hash,
18024 "feed_hash": pointer.feed_hash,
18025 "bytes_base64": STANDARD.encode(&raw),
18026 }],
18027 "next_after": 1,
18028 "complete": true,
18029 })
18030 .to_string();
18031 let identity = V2HeadIdentity {
18032 custody: "self".to_string(),
18033 fingerprint: remote.identity.fingerprint.clone(),
18034 public_key_spki: remote.identity.public_key_spki.clone(),
18035 previous: Vec::new(),
18036 rotations: Vec::new(),
18037 };
18038 let checkpoint = TrustState {
18039 v: 2,
18040 origin: "unused".to_string(),
18041 requested: TEST_BRAIN_ID.to_string(),
18042 brain: TEST_BRAIN_ID.to_string(),
18043 home: None,
18044 anchor: remote.key.multikey.clone(),
18045 current: remote.key.multikey,
18046 head_seq: legacy_item.entry.seq,
18047 feed_hash: Some(legacy_item.hash.clone()),
18048 rotations: Vec::new(),
18049 hub_signer: None,
18050 protocol_profile: None,
18051 };
18052 let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
18053 let state = tempfile::tempdir().unwrap();
18054 let cfg = test_hub_config(hub, state.path().to_path_buf());
18055 verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
18056 server.join().unwrap();
18057
18058 let mut wrong = checkpoint;
18059 wrong.feed_hash = Some("0".repeat(64));
18060 let (hub, server) = scripted_json_hub(vec![(
18061 200,
18062 json!({
18063 "v": 2,
18064 "head_seq": 1,
18065 "head_commit_hash": pointer.commit_hash,
18066 "head_feed_hash": pointer.feed_hash,
18067 "entries": [{
18068 "seq": 1,
18069 "commit_hash": pointer.commit_hash,
18070 "feed_hash": pointer.feed_hash,
18071 "bytes_base64": STANDARD.encode(&raw),
18072 }],
18073 "next_after": 1,
18074 "complete": true,
18075 })
18076 .to_string(),
18077 )]);
18078 let state = tempfile::tempdir().unwrap();
18079 let cfg = test_hub_config(hub, state.path().to_path_buf());
18080 assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
18081 server.join().unwrap();
18082 }
18083
18084 #[test]
18085 fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
18086 let rng = ring::rand::SystemRandom::new();
18087 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18088 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
18089 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18090 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
18091 let (old_spki, old_multikey) = public_identity_for(&old);
18092 let (new_spki, new_multikey) = public_identity_for(&new);
18093 let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
18094 v: 1,
18095 op: "rotate",
18096 brain: &old_multikey,
18097 public_key: &old_spki,
18098 new_brain: &new_multikey,
18099 new_public_key: &new_spki,
18100 prior_head_seq: 1,
18101 prior_feed_hash: Some(&"9".repeat(64)),
18102 ts: "2026-08-19T12:01:00.000Z".to_string(),
18103 })
18104 .unwrap();
18105 let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
18106 let rotation = format!(
18107 "{},\"sig\":\"{}\"}}",
18108 &rotation_unsigned[..rotation_unsigned.len() - 1],
18109 rotation_sig
18110 );
18111 let identity = V2HeadIdentity {
18112 custody: "self".to_string(),
18113 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
18114 public_key_spki: new_spki.clone(),
18115 previous: vec![V2PreviousIdentity {
18116 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
18117 public_key_spki: old_spki.clone(),
18118 }],
18119 rotations: vec![rotation],
18120 };
18121 let commit = |seq: u64,
18122 epoch: u64,
18123 multikey: &str,
18124 spki: &str,
18125 pair: &ring::signature::Ed25519KeyPair| {
18126 let value = json!({
18127 "actor_ref": "a".repeat(64),
18128 "asset_root": Value::Null,
18129 "brain": multikey,
18130 "changes_sha256": "b".repeat(64),
18131 "control_revision": "c".repeat(64),
18132 "materializer": "dbmd-projection-v1",
18133 "op": "changeset",
18134 "parent_asset_root": Value::Null,
18135 "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
18136 "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
18137 "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
18138 "public_key": spki,
18139 "seq": seq,
18140 "signer_epoch": epoch,
18141 "state_root": "1".repeat(64),
18142 "ts": "2026-08-19T12:00:00.000Z",
18143 "v": 2,
18144 "v1_bridge": Value::Null,
18145 });
18146 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
18147 let mut object = value.as_object().unwrap().clone();
18148 object.insert(
18149 "sig".to_string(),
18150 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
18151 );
18152 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
18153 };
18154
18155 assert!(verified_v2_commit_object(
18156 &commit(1, 1, &old_multikey, &old_spki, &old),
18157 &identity,
18158 )
18159 .is_ok());
18160 assert!(verified_v2_commit_object(
18161 &commit(2, 2, &new_multikey, &new_spki, &new),
18162 &identity,
18163 )
18164 .is_ok());
18165 assert!(verified_v2_commit_object(
18166 &commit(2, 1, &old_multikey, &old_spki, &old),
18167 &identity,
18168 )
18169 .is_err());
18170 assert!(verified_v2_commit_object(
18171 &commit(1, 2, &new_multikey, &new_spki, &new),
18172 &identity,
18173 )
18174 .is_err());
18175 }
18176
18177 #[test]
18178 fn a_self_custody_entry_verifies_like_any_hub_entry() {
18179 let rng = ring::rand::SystemRandom::new();
18180 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18181 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18182 let (spki, multikey) = public_identity_for(&pair);
18183 let key = AgentSigningKey {
18184 pkcs8: pkcs8.as_ref().to_vec(),
18185 multikey: multikey.clone(),
18186 public_key_spki: spki.clone(),
18187 };
18188 let files = vec![WireFeedFile {
18189 path: "DB.md".to_string(),
18190 sha256: "a".repeat(64),
18191 bytes: 3,
18192 }];
18193 let raw = self_custody_entry(
18194 &key,
18195 1,
18196 "2026-07-23T12:00:00.000Z".to_string(),
18197 &"c".repeat(64),
18198 &files,
18199 None,
18200 )
18201 .unwrap();
18202 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
18206 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
18207 let item = FeedItem { hash, entry };
18208 let identity = FeedIdentity {
18209 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
18210 public_key_spki: spki,
18211 previous: Vec::new(),
18212 rotations: Vec::new(),
18213 };
18214 assert!(verify_feed_item(&item, &identity).is_ok());
18215 }
18216
18217 #[test]
18218 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
18219 let rng = ring::rand::SystemRandom::new();
18220 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18221 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
18222 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18223 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
18224 let (old_spki, old_multikey) = public_identity_for(&old);
18225 let (new_spki, new_multikey) = public_identity_for(&new);
18226 let unsigned = serde_json::to_string(&UnsignedRotation {
18227 v: 1,
18228 op: "rotate",
18229 brain: &old_multikey,
18230 public_key: &old_spki,
18231 new_brain: &new_multikey,
18232 new_public_key: &new_spki,
18233 prior_head_seq: 1,
18234 prior_feed_hash: Some(&"a".repeat(64)),
18235 ts: "2026-07-30T12:00:00.000Z".to_string(),
18236 })
18237 .unwrap();
18238 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
18239 let rotation = format!(
18240 "{},\"sig\":\"{}\"}}",
18241 &unsigned[..unsigned.len() - 1],
18242 signature
18243 );
18244 let identity = FeedIdentity {
18245 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
18246 public_key_spki: new_spki,
18247 previous: vec![PreviousIdentity {
18248 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
18249 public_key_spki: old_spki,
18250 }],
18251 rotations: vec![rotation],
18252 };
18253 let pin = TrustState {
18254 v: 2,
18255 origin: "https://hub.example".to_string(),
18256 requested: "brain".to_string(),
18257 brain: "brain".to_string(),
18258 home: None,
18259 anchor: old_multikey.clone(),
18260 current: old_multikey.clone(),
18261 head_seq: 1,
18262 feed_hash: Some("a".repeat(64)),
18263 rotations: Vec::new(),
18264 hub_signer: None,
18265 protocol_profile: None,
18266 };
18267 assert_eq!(
18268 verify_identity_chain(&identity, Some(&pin)).unwrap(),
18269 old_multikey
18270 );
18271 let mut accepted = pin.clone();
18272 accepted.current = new_multikey.clone();
18273 accepted.rotations = identity.rotations.clone();
18274 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
18275 v: 1,
18276 op: "rotate",
18277 brain: &old_multikey,
18278 public_key: &identity.previous[0].public_key_spki,
18279 new_brain: &new_multikey,
18280 new_public_key: &identity.public_key_spki,
18281 prior_head_seq: 1,
18282 prior_feed_hash: Some(&"a".repeat(64)),
18283 ts: "2026-07-30T12:00:01.000Z".to_string(),
18284 })
18285 .unwrap();
18286 let alternate_signature =
18287 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
18288 let mut rewritten = identity.clone();
18289 rewritten.rotations[0] = format!(
18290 "{},\"sig\":\"{}\"}}",
18291 &alternate_unsigned[..alternate_unsigned.len() - 1],
18292 alternate_signature
18293 );
18294 assert!(
18295 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
18296 "an alternate valid statement must not rewrite accepted history"
18297 );
18298
18299 let mut stale_entry = FeedEntry {
18300 v: 1,
18301 seq: 2,
18302 ts: "2026-07-30T12:01:00.000Z".to_string(),
18303 brain: pin.current.clone(),
18304 public_key: identity.previous[0].public_key_spki.clone(),
18305 kind: "push".to_string(),
18306 op: "snapshot".to_string(),
18307 pack_sha256: "b".repeat(64),
18308 files: Vec::new(),
18309 removed: Vec::new(),
18310 prev_entry_hash: pin.feed_hash.clone(),
18311 sig: String::new(),
18312 };
18313 let stale_unsigned = UnsignedFeedEntry {
18314 v: stale_entry.v,
18315 seq: stale_entry.seq,
18316 ts: &stale_entry.ts,
18317 brain: &stale_entry.brain,
18318 public_key: &stale_entry.public_key,
18319 kind: &stale_entry.kind,
18320 op: &stale_entry.op,
18321 pack_sha256: &stale_entry.pack_sha256,
18322 files: &stale_entry.files,
18323 removed: &stale_entry.removed,
18324 prev_entry_hash: &stale_entry.prev_entry_hash,
18325 };
18326 stale_entry.sig = URL_SAFE_NO_PAD.encode(
18327 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
18328 .as_ref(),
18329 );
18330 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
18331 stale_exact.push(b'\n');
18332 let stale_item = FeedItem {
18333 hash: content_sha256(&stale_exact),
18334 entry: stale_entry,
18335 };
18336 assert!(
18337 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
18338 .is_err(),
18339 "a key retired before the checkpoint must never append after it"
18340 );
18341 assert!(
18342 verify_feed_item(&stale_item, &identity).is_err(),
18343 "an old key must never append after its signed rotation boundary"
18344 );
18345
18346 let mut missing = identity.clone();
18347 missing.rotations.clear();
18348 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
18349
18350 let mut tampered = identity;
18351 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
18352 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
18353 }
18354
18355 #[cfg(unix)]
18356 #[test]
18357 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
18358 use std::os::unix::fs::symlink;
18359
18360 let dir = tempfile::tempdir().unwrap();
18361 let target = dir.path().join("valuable.txt");
18362 let planted = dir.path().join("agent.key");
18363 std::fs::write(&target, "do not overwrite").unwrap();
18364 symlink(&target, &planted).unwrap();
18365
18366 assert!(matches!(
18367 generate_agent_key(&planted),
18368 Err(LinkError::BadAgentKey { .. })
18369 ));
18370 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
18371 }
18372
18373 #[cfg(unix)]
18374 #[test]
18375 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
18376 use std::os::unix::fs::symlink;
18377
18378 let root = tempfile::tempdir().unwrap();
18379 let outside = tempfile::tempdir().unwrap();
18380 symlink(outside.path(), root.path().join("redirect")).unwrap();
18381
18382 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
18383 assert!(!outside.path().join("agent.key").exists());
18384 }
18385
18386 #[test]
18389 fn address_bare_brain_with_and_without_sigil() {
18390 for raw in ["@acme-ops", "acme-ops"] {
18391 let a = Address::parse(raw).expect(raw);
18392 assert_eq!(a.brain, "acme-ops");
18393 assert_eq!(a.target, None);
18394 }
18395 }
18396
18397 #[test]
18398 fn address_ulid_target_parses_as_id() {
18399 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
18400 assert_eq!(a.brain, "acme");
18401 assert_eq!(
18402 a.target,
18403 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
18404 );
18405 }
18406
18407 #[test]
18408 fn address_md_path_target_parses_as_path() {
18409 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
18410 assert_eq!(
18411 a.target,
18412 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
18413 );
18414 }
18415
18416 #[test]
18417 fn address_rejects_malformed_forms() {
18418 for raw in [
18419 "",
18420 "@",
18421 "@/x",
18422 "@acme/",
18423 "@acme/../etc/passwd",
18424 "@acme/records/.hidden.md",
18425 "@ACME", "@acme/notes/x.txt", "@a b", ] {
18429 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
18430 }
18431 }
18432
18433 #[test]
18436 fn safe_paths_accept_store_shapes_and_reject_escapes() {
18437 for ok in [
18438 "DB.md",
18439 "assets.jsonl",
18440 "records/clients/lumio.md",
18441 "sources/emails/2026/07/x.md",
18442 ] {
18443 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
18444 }
18445 for bad in [
18446 "",
18447 "/etc/passwd",
18448 "../up.md",
18449 "records/../../up.md",
18450 "records//x.md",
18451 ".dbmd/config",
18452 "records/.hidden/x.md",
18453 "records/a b.md",
18454 "records\\win.md",
18455 ] {
18456 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
18457 }
18458 }
18459
18460 #[cfg(unix)]
18461 #[test]
18462 fn opened_destination_capability_survives_an_ancestor_path_swap() {
18463 use std::os::unix::fs::symlink;
18464
18465 let work = tempfile::tempdir().unwrap();
18466 let outside = tempfile::tempdir().unwrap();
18467 let original = work.path().join("destination");
18468 let moved = work.path().join("destination-moved");
18469 let directory = open_or_create_dir_nofollow(&original).unwrap();
18470
18471 std::fs::rename(&original, &moved).unwrap();
18472 symlink(outside.path(), &original).unwrap();
18473 write_pull_entries_beneath_dir(
18474 &directory,
18475 &[("records/note.md".to_string(), b"held inode".to_vec())],
18476 )
18477 .unwrap();
18478
18479 assert_eq!(
18480 std::fs::read(moved.join("records/note.md")).unwrap(),
18481 b"held inode"
18482 );
18483 assert!(!outside.path().join("records/note.md").exists());
18484 }
18485
18486 #[test]
18490 fn hub_config_flag_beats_file_and_requires_some_source() {
18491 let dir = tempfile::tempdir().unwrap();
18492 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
18493 std::fs::write(
18494 dir.path().join(CONFIG_REL_PATH),
18495 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
18496 )
18497 .unwrap();
18498
18499 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
18500 assert_eq!(from_flag.hub, "https://flag.example.com");
18501
18502 let from_file = hub_config(None, dir.path()).unwrap();
18503 assert_eq!(from_file.hub, "https://file.example.com");
18504
18505 let none = hub_config(None, tempfile::tempdir().unwrap().path());
18506 assert!(matches!(none, Err(LinkError::NoHub)));
18507 }
18508
18509 #[test]
18510 fn https_guard_allows_loopback_only_for_plain_http() {
18511 assert!(assert_safe_hub("https://hub.example.com").is_ok());
18512 assert!(assert_safe_hub("http://localhost:3000").is_ok());
18513 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
18514 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
18515 assert!(matches!(
18516 assert_safe_hub("http://hub.example.com"),
18517 Err(LinkError::UnsafeHub { .. })
18518 ));
18519 assert!(matches!(
18520 assert_safe_hub("hub.example.com"),
18521 Err(LinkError::UnsafeHub { .. })
18522 ));
18523 assert!(matches!(
18524 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
18525 Err(LinkError::UnsafeHub { .. })
18526 ));
18527 assert!(matches!(
18528 assert_safe_hub("https://hub.example.com@attacker.example"),
18529 Err(LinkError::UnsafeHub { .. })
18530 ));
18531 assert!(matches!(
18532 assert_safe_hub("https://hub.example.com/base"),
18533 Err(LinkError::UnsafeHub { .. })
18534 ));
18535 }
18536
18537 #[test]
18538 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
18539 for blocked in [
18540 "127.0.0.1",
18541 "10.0.0.1",
18542 "100.64.0.1",
18543 "169.254.169.254",
18544 "172.16.0.1",
18545 "192.168.0.1",
18546 "192.88.99.1",
18547 "198.18.0.1",
18548 "203.0.113.1",
18549 "::1",
18550 "fe80::1",
18551 "fd00::1",
18552 "2001:db8::1",
18553 "2001:1::1",
18554 "2002:7f00:1::",
18555 "3fff::1",
18556 ] {
18557 assert!(
18558 !is_public_registry_ip(blocked.parse().unwrap()),
18559 "must block {blocked}"
18560 );
18561 }
18562 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
18563 assert!(is_public_registry_ip(
18564 "2606:4700:4700::1111".parse().unwrap()
18565 ));
18566 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
18567 }
18568
18569 #[test]
18570 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
18571 use ureq::Resolver as _;
18572
18573 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
18574 let resolver = PinnedRegistryResolver {
18575 netloc: "home.example:443".to_string(),
18576 addresses: vec![pinned],
18577 };
18578 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
18579 assert!(resolver.resolve("127.0.0.1:443").is_err());
18580 assert_eq!(
18581 resolver.resolve("home.example:443").unwrap(),
18582 vec![pinned],
18583 "subsequent connects reuse the validated answer instead of DNS"
18584 );
18585 }
18586
18587 #[test]
18588 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
18589 let cfg = HubConfig {
18590 hub: "https://hub.example".to_string(),
18591 key: None,
18592 agent_key: None,
18593 brain_key: None,
18594 state_dir: tempfile::tempdir().unwrap().keep(),
18595 store_selected: false,
18596 };
18597 assert!(
18598 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
18599 "a production hub must not turn its presigned URL into an SSRF primitive"
18600 );
18601
18602 let store_selected = HubConfig {
18603 hub: "https://127.0.0.1".to_string(),
18604 store_selected: true,
18605 ..cfg
18606 };
18607 assert!(
18608 hub_agent(&store_selected).is_err(),
18609 "bytes in a cloned store must not select a private-network hub"
18610 );
18611 }
18612
18613 #[test]
18614 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
18615 assert_eq!(
18616 one_past_bounded_limit(MAX_PACK_BYTES),
18617 Some(MAX_PACK_BYTES + 1),
18618 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
18619 );
18620 assert_eq!(
18621 presigned_download_read_limit(),
18622 MAX_PACK_BYTES + 1,
18623 "the presigned reader is capped by the client constant, not a hub response"
18624 );
18625 assert_eq!(
18626 one_past_bounded_limit(u64::MAX),
18627 None,
18628 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
18629 );
18630 }
18631
18632 #[test]
18633 fn https_guard_matches_the_scheme_case_insensitively() {
18634 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
18637 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
18638 assert!(matches!(
18640 assert_safe_hub("HTTP://hub.example.com"),
18641 Err(LinkError::UnsafeHub { .. })
18642 ));
18643 }
18644
18645 #[test]
18646 fn clean_key_refuses_paste_artifacts_without_echoing() {
18647 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
18648 for bad in ["vc account", "vc\naccount", "ключ", ""] {
18649 let err = clean_key(bad).unwrap_err();
18650 assert!(matches!(err, LinkError::BadKey));
18651 assert!(
18652 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
18653 "error must not echo the key"
18654 );
18655 }
18656 }
18657
18658 fn dead_hub() -> HubConfig {
18664 HubConfig {
18665 hub: "http://127.0.0.1:9".to_string(),
18666 key: Some("k".to_string()),
18667 agent_key: None,
18668 brain_key: None,
18669 state_dir: PathBuf::from("."),
18670 store_selected: false,
18671 }
18672 }
18673
18674 #[test]
18675 fn request_retries_a_connection_failure_before_sending() {
18676 use std::io::{Read as _, Write as _};
18677 use std::net::TcpListener;
18678 use std::thread;
18679 use std::time::Duration;
18680
18681 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
18682 let address = probe.local_addr().unwrap();
18683 drop(probe);
18684 let server = thread::spawn(move || {
18685 thread::sleep(Duration::from_millis(40));
18686 let listener = TcpListener::bind(address).unwrap();
18687 let (mut stream, _) = listener.accept().unwrap();
18688 let mut request_bytes = [0_u8; 1024];
18689 let _ = stream.read(&mut request_bytes).unwrap();
18690 stream
18691 .write_all(
18692 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18693 )
18694 .unwrap();
18695 });
18696 let cfg = HubConfig {
18697 hub: format!("http://{address}"),
18698 key: None,
18699 agent_key: None,
18700 brain_key: None,
18701 state_dir: tempfile::tempdir().unwrap().keep(),
18702 store_selected: false,
18703 };
18704
18705 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
18706 assert_eq!(response.status, 200);
18707 assert_eq!(response.body, Some(json!({ "ok": true })));
18708 server.join().unwrap();
18709 }
18710
18711 #[test]
18712 fn a_commit_goes_back_for_a_receipt_it_lost() {
18713 use std::io::Write as _;
18714 use std::net::TcpListener;
18715 use std::thread;
18716
18717 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18723 let address = listener.local_addr().unwrap();
18724 let server = thread::spawn(move || {
18725 let (mut first, _) = listener.accept().unwrap();
18727 drain_test_http_request(&mut first);
18728 first
18729 .write_all(
18730 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
18731 )
18732 .unwrap();
18733 drop(first);
18734 let (mut second, _) = listener.accept().unwrap();
18736 drain_test_http_request(&mut second);
18737 second
18738 .write_all(
18739 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\"}",
18740 )
18741 .unwrap();
18742 });
18743 let cfg = HubConfig {
18744 hub: format!("http://{address}"),
18745 key: Some("k".to_string()),
18746 agent_key: None,
18747 brain_key: None,
18748 state_dir: tempfile::tempdir().unwrap().keep(),
18749 store_selected: false,
18750 };
18751
18752 let response = request_patient(
18753 &cfg,
18754 "POST",
18755 "/api/hub/brains/b/v2/commits",
18756 Some(&json!({ "mutation_id": "dbmd-1" })),
18757 Auth::Required,
18758 )
18759 .expect("the receipt is collected on the second ask");
18760 assert_eq!(response.status, 200);
18761 assert_eq!(
18762 response
18763 .body
18764 .as_ref()
18765 .and_then(|value| value.get("outcome"))
18766 .and_then(Value::as_str),
18767 Some("converged"),
18768 "an already-applied mutation answers with its receipt"
18769 );
18770 server.join().unwrap();
18771 }
18772
18773 #[test]
18774 fn a_patient_commit_waits_for_its_typed_post_acceptance_receipt_lag() {
18775 use std::io::Write as _;
18776 use std::net::TcpListener;
18777 use std::thread;
18778
18779 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18780 let address = listener.local_addr().unwrap();
18781 let server = thread::spawn(move || {
18782 let lag = br#"{"error":"validation recovery state is not at the source head","details":{"code":"validation_index_catching_up"}}"#;
18783 let receipt = br#"{"v":2,"outcome":"converged"}"#;
18784 for (status, body) in [
18785 ("422 Unprocessable Entity", lag.as_slice()),
18786 ("200 OK", receipt.as_slice()),
18787 ] {
18788 let (mut stream, _) = listener.accept().unwrap();
18789 drain_test_http_request(&mut stream);
18790 write!(
18791 stream,
18792 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
18793 body.len()
18794 )
18795 .unwrap();
18796 stream.write_all(body).unwrap();
18797 }
18798 });
18799 let cfg = HubConfig {
18800 hub: format!("http://{address}"),
18801 key: Some("k".to_string()),
18802 agent_key: None,
18803 brain_key: None,
18804 state_dir: tempfile::tempdir().unwrap().keep(),
18805 store_selected: false,
18806 };
18807
18808 let response = request_patient(
18809 &cfg,
18810 "POST",
18811 "/api/hub/brains/b/v2/commits",
18812 Some(&json!({ "mutation_id": "dbmd-1" })),
18813 Auth::Required,
18814 )
18815 .expect("typed projection lag is retried until the exact receipt is available");
18816 assert_eq!(response.status, 200);
18817 assert_eq!(
18818 response
18819 .body
18820 .as_ref()
18821 .and_then(|value| value.get("outcome"))
18822 .and_then(Value::as_str),
18823 Some("converged")
18824 );
18825 server.join().unwrap();
18826 }
18827
18828 #[test]
18829 fn a_mutation_body_that_dies_mid_stream_is_a_transport_failure() {
18830 use std::io::{Read as _, Write as _};
18831 use std::net::TcpListener;
18832 use std::thread;
18833
18834 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18840 let address = listener.local_addr().unwrap();
18841 let server = thread::spawn(move || {
18842 let (mut stream, _) = listener.accept().unwrap();
18843 let mut request_bytes = [0_u8; 1024];
18844 let _ = stream.read(&mut request_bytes).unwrap();
18845 stream
18847 .write_all(
18848 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18849 )
18850 .unwrap();
18851 });
18852 let cfg = HubConfig {
18853 hub: format!("http://{address}"),
18854 key: None,
18855 agent_key: None,
18856 brain_key: None,
18857 state_dir: tempfile::tempdir().unwrap().keep(),
18858 store_selected: false,
18859 };
18860
18861 let error = request(&cfg, "POST", "/truncated", None, Auth::None)
18862 .expect_err("a truncated body must not read as success");
18863 match error {
18864 LinkError::Transport { hub, .. } => {
18865 assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
18866 }
18867 other => panic!("expected a transport failure, got {other:?}"),
18868 }
18869 server.join().unwrap();
18870 }
18871
18872 #[test]
18873 fn a_safe_get_retries_when_its_body_dies_mid_stream() {
18874 use std::io::{Read as _, Write as _};
18875 use std::net::{TcpListener, TcpStream};
18876 use std::thread;
18877
18878 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18879 let address = listener.local_addr().unwrap();
18880 let server = thread::spawn(move || {
18881 let read_request = |stream: &mut TcpStream| {
18882 let mut request = Vec::new();
18883 let mut bytes = [0_u8; 1024];
18884 loop {
18885 let read = stream.read(&mut bytes).unwrap();
18886 if read == 0 {
18887 break;
18888 }
18889 request.extend_from_slice(&bytes[..read]);
18890 let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
18891 else {
18892 continue;
18893 };
18894 let headers = String::from_utf8_lossy(&request[..header_end]);
18895 let content_length = headers
18896 .lines()
18897 .find_map(|line| {
18898 let (name, value) = line.split_once(':')?;
18899 name.eq_ignore_ascii_case("content-length")
18900 .then(|| value.trim().parse::<usize>().ok())
18901 .flatten()
18902 })
18903 .unwrap_or(0);
18904 if request.len() >= header_end + 4 + content_length {
18905 break;
18906 }
18907 }
18908 };
18909 let (mut first, _) = listener.accept().unwrap();
18910 read_request(&mut first);
18911 first
18912 .write_all(
18913 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18914 )
18915 .unwrap();
18916 drop(first);
18917
18918 let (mut second, _) = listener.accept().unwrap();
18919 read_request(&mut second);
18920 second
18921 .write_all(
18922 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18923 )
18924 .unwrap();
18925 });
18926 let cfg = HubConfig {
18927 hub: format!("http://{address}"),
18928 key: None,
18929 agent_key: None,
18930 brain_key: None,
18931 state_dir: tempfile::tempdir().unwrap().keep(),
18932 store_selected: false,
18933 };
18934
18935 let response = request(&cfg, "GET", "/retry-body", None, Auth::None)
18936 .expect("a safe read retries the interrupted body");
18937 assert_eq!(response.status, 200);
18938 assert_eq!(response.body, Some(json!({ "ok": true })));
18939 server.join().unwrap();
18940 }
18941
18942 #[test]
18943 fn an_explicit_read_only_post_retries_when_its_body_dies_mid_stream() {
18944 use std::io::{Read as _, Write as _};
18945 use std::net::{TcpListener, TcpStream};
18946 use std::thread;
18947
18948 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18949 let address = listener.local_addr().unwrap();
18950 let server = thread::spawn(move || {
18951 let read_request = |stream: &mut TcpStream| {
18952 let mut request = Vec::new();
18953 let mut bytes = [0_u8; 1024];
18954 loop {
18955 let read = stream.read(&mut bytes).unwrap();
18956 if read == 0 {
18957 break;
18958 }
18959 request.extend_from_slice(&bytes[..read]);
18960 let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
18961 else {
18962 continue;
18963 };
18964 let headers = String::from_utf8_lossy(&request[..header_end]);
18965 let content_length = headers
18966 .lines()
18967 .find_map(|line| {
18968 let (name, value) = line.split_once(':')?;
18969 name.eq_ignore_ascii_case("content-length")
18970 .then(|| value.trim().parse::<usize>().ok())
18971 .flatten()
18972 })
18973 .unwrap_or(0);
18974 if request.len() >= header_end + 4 + content_length {
18975 break;
18976 }
18977 }
18978 };
18979 let (mut first, _) = listener.accept().unwrap();
18980 read_request(&mut first);
18981 first
18982 .write_all(
18983 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18984 )
18985 .unwrap();
18986 drop(first);
18987
18988 let (mut second, _) = listener.accept().unwrap();
18989 read_request(&mut second);
18990 second
18991 .write_all(
18992 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18993 )
18994 .unwrap();
18995 });
18996 let cfg = HubConfig {
18997 hub: format!("http://{address}"),
18998 key: None,
18999 agent_key: None,
19000 brain_key: None,
19001 state_dir: tempfile::tempdir().unwrap().keep(),
19002 store_selected: false,
19003 };
19004
19005 let response = request_raw_retryable_read(
19006 &cfg,
19007 "POST",
19008 "/v2/stream",
19009 Some(&json!({ "files": ["proof"] })),
19010 Auth::None,
19011 1_024,
19012 )
19013 .expect("an explicitly safe POST retries the interrupted body");
19014 assert_eq!(response.status, 200);
19015 assert_eq!(
19016 serde_json::from_slice::<Value>(&response.body).unwrap(),
19017 json!({ "ok": true })
19018 );
19019 server.join().unwrap();
19020 }
19021
19022 #[test]
19023 fn object_store_transport_errors_never_render_presigned_urls() {
19024 use std::net::TcpListener;
19025
19026 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19027 let address = listener.local_addr().unwrap();
19028 drop(listener);
19029 let signature = "do-not-render-this-presigned-signature";
19030 let raw =
19031 format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
19032 let error = ureq::get(&raw)
19033 .timeout(std::time::Duration::from_millis(250))
19034 .call()
19035 .expect_err("the closed local port must fail");
19036 let ureq::Error::Transport(transport) = error else {
19037 panic!("expected a transport failure");
19038 };
19039
19040 let rendered = object_store_transport_error(transport).to_string();
19041 assert!(rendered.contains("the object store"));
19042 assert!(rendered.contains("network error"));
19043 assert!(!rendered.contains(&raw));
19044 assert!(!rendered.contains(signature));
19045 assert!(!rendered.contains("X-Amz-"));
19046 }
19047
19048 #[test]
19049 fn endpoint_cap_refuses_a_body_before_json_parsing() {
19050 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
19051 let cfg = HubConfig {
19052 hub,
19053 key: None,
19054 agent_key: None,
19055 brain_key: None,
19056 state_dir: tempfile::tempdir().unwrap().keep(),
19057 store_selected: false,
19058 };
19059
19060 assert!(matches!(
19061 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
19062 Err(LinkError::ResponseTooLarge { .. })
19063 ));
19064 server.join().unwrap();
19065 }
19066
19067 #[test]
19068 fn overall_deadline_stops_a_dribbled_response_body() {
19069 use std::io::{Read as _, Write as _};
19070 use std::net::TcpListener;
19071 use std::time::{Duration, Instant};
19072
19073 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19074 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
19075 let server = std::thread::spawn(move || {
19076 let (mut stream, _) = listener.accept().unwrap();
19077 let mut request = [0_u8; 1024];
19078 let _ = stream.read(&mut request);
19079 stream
19080 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
19081 .unwrap();
19082 for byte in [b'x'; 32] {
19083 if stream.write_all(&[byte]).is_err() {
19084 break;
19085 }
19086 std::thread::sleep(Duration::from_millis(40));
19087 }
19088 });
19089 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
19090 let started = Instant::now();
19091 let response = http.get(&url).call().unwrap();
19092 let mut body = Vec::new();
19093 let error = response
19094 .into_reader()
19095 .read_to_end(&mut body)
19096 .expect_err("per-read progress must not reset the overall deadline");
19097 assert!(
19098 started.elapsed() < Duration::from_millis(700),
19099 "dribbled body exceeded the wall-clock budget: {error}"
19100 );
19101 server.join().unwrap();
19102 }
19103
19104 #[test]
19105 fn overall_deadline_stops_a_stalled_upload() {
19106 use std::net::TcpListener;
19107 use std::time::{Duration, Instant};
19108
19109 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19110 let url = format!("http://{}/upload", listener.local_addr().unwrap());
19111 let server = std::thread::spawn(move || {
19112 let (_stream, _) = listener.accept().unwrap();
19113 std::thread::sleep(Duration::from_millis(600));
19116 });
19117 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
19118 let body = vec![0x5a; 32 * 1024 * 1024];
19119 let started = Instant::now();
19120 let error = http
19121 .put(&url)
19122 .send_bytes(&body)
19123 .expect_err("stalled request-body writes must time out");
19124 assert!(
19125 started.elapsed() < Duration::from_millis(700),
19126 "stalled upload exceeded the wall-clock budget: {error}"
19127 );
19128 server.join().unwrap();
19129 }
19130
19131 #[test]
19132 fn presigned_source_retries_share_one_upload_deadline() {
19133 use std::net::TcpListener;
19134 use std::time::{Duration, Instant};
19135
19136 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19137 let address = listener.local_addr().unwrap();
19138 let signature = "do-not-render-this-stalled-upload-signature";
19139 let url = format!(
19140 "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
19141 );
19142 let server = std::thread::spawn(move || {
19143 let (_stream, _) = listener.accept().unwrap();
19144 std::thread::sleep(Duration::from_millis(600));
19148 });
19149
19150 let directory = tempfile::tempdir().unwrap();
19151 std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
19152 std::fs::create_dir(directory.path().join("records")).unwrap();
19153 let relative = "records/stalled.bin";
19154 let bytes = vec![0x5a; 32 * 1024 * 1024];
19155 std::fs::write(directory.path().join(relative), &bytes).unwrap();
19156 let store = Store::open_strict(directory.path()).unwrap();
19157 let cfg = HubConfig {
19158 hub: format!("http://{address}"),
19159 key: None,
19160 agent_key: None,
19161 brain_key: None,
19162 state_dir: tempfile::tempdir().unwrap().keep(),
19163 store_selected: false,
19164 };
19165 let source = V2UploadSource {
19166 path: relative.to_string(),
19167 bytes: bytes.len() as u64,
19168 };
19169
19170 let started = Instant::now();
19171 let error = put_presigned_source_with_budget(
19172 &cfg,
19173 &url,
19174 &json!({ "content-length": source.bytes.to_string() }),
19175 &store,
19176 &source,
19177 None,
19178 Duration::from_millis(150),
19179 )
19180 .expect_err("a black-holed upload must leave at its shared deadline");
19181 assert!(
19182 started.elapsed() < Duration::from_millis(700),
19183 "presigned retries exceeded their shared budget: {error}"
19184 );
19185 let rendered = error.to_string();
19186 assert!(rendered.contains("the object store"));
19187 assert!(!rendered.contains(&url));
19188 assert!(!rendered.contains(signature));
19189 server.join().unwrap();
19190 }
19191
19192 #[test]
19193 fn verb_entry_gates_accept_the_hub_ref_shapes() {
19194 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
19195 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
19196 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
19197 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
19198 }
19199 }
19200
19201 #[test]
19202 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
19203 let cfg = dead_hub();
19204 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
19205 assert!(
19206 matches!(
19207 sync_pull(&cfg, bad, None),
19208 Err(LinkError::BadAddress { .. })
19209 ),
19210 "sync_pull must refuse {bad:?}"
19211 );
19212 assert!(
19213 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
19214 "sync_push must refuse {bad:?}"
19215 );
19216 assert!(
19217 matches!(
19218 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
19219 Err(LinkError::BadAddress { .. })
19220 ),
19221 "grant_issue must refuse {bad:?}"
19222 );
19223 assert!(
19224 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
19225 "grant_list must refuse {bad:?}"
19226 );
19227 assert!(
19228 matches!(
19229 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
19230 Err(LinkError::BadAddress { .. })
19231 ),
19232 "grant_revoke must refuse brain {bad:?}"
19233 );
19234 assert!(
19235 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
19236 "head must refuse {bad:?}"
19237 );
19238 }
19239 }
19240
19241 #[test]
19242 fn grant_revoke_refuses_url_reshaping_grant_ids() {
19243 let cfg = dead_hub();
19244 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
19245 assert!(
19246 matches!(
19247 grant_revoke(&cfg, "acme", bad),
19248 Err(LinkError::BadGrantId { .. })
19249 ),
19250 "grant_revoke must refuse grant id {bad:?}"
19251 );
19252 }
19253 }
19254
19255 #[test]
19256 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
19257 let cfg = dead_hub();
19258 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
19259 assert!(
19260 matches!(
19261 propose(&cfg, bad, "intake", "hi"),
19262 Err(LinkError::BadAddress { .. })
19263 ),
19264 "propose must refuse handle {bad:?}"
19265 );
19266 }
19267 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
19268 assert!(matches!(
19269 propose(&cfg, "acme-site", "intake", &oversize),
19270 Err(LinkError::ProposeTooLarge { .. })
19271 ));
19272 assert!(matches!(
19275 propose(&cfg, "acme-site", "intake", "hi"),
19276 Err(LinkError::Transport { .. })
19277 ));
19278 }
19279
19280 #[test]
19281 fn resolve_refuses_a_hand_built_unsafe_address() {
19282 let cfg = dead_hub();
19283 for brain in ["../up", "a/b", "a?x", "a#f"] {
19284 let addr = Address {
19285 brain: brain.to_string(),
19286 target: None,
19287 };
19288 assert!(
19289 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
19290 "resolve must refuse brain {brain:?}"
19291 );
19292 }
19293 for target in [
19294 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
19295 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
19297 AddressTarget::Path("records/x.md#frag".to_string()),
19298 ] {
19299 let addr = Address {
19300 brain: "acme".to_string(),
19301 target: Some(target.clone()),
19302 };
19303 assert!(
19304 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
19305 "resolve must refuse target {target:?}"
19306 );
19307 }
19308 }
19309
19310 #[test]
19311 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
19312 let mut local = std::collections::BTreeMap::new();
19313 local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
19314 local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
19315 let mut remote = std::collections::BTreeMap::new();
19316 remote.insert(
19317 "records/a.md".to_string(),
19318 V2BaselineFile {
19319 sha256: "c".repeat(64),
19320 bytes: 1,
19321 proof: None,
19322 },
19323 );
19324 remote.insert(
19325 "records/b.md".to_string(),
19326 V2BaselineFile {
19327 sha256: "b".repeat(64),
19328 bytes: 1,
19329 proof: None,
19330 },
19331 );
19332 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
19333 }
19334
19335 #[test]
19336 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
19337 let local = std::collections::BTreeMap::new();
19338 let mut remote = std::collections::BTreeMap::new();
19339 remote.insert(
19340 "private/local.md".to_string(),
19341 V2BaselineFile {
19342 sha256: "d".repeat(64),
19343 bytes: 1,
19344 proof: None,
19345 },
19346 );
19347 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
19348 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
19349 }
19350
19351 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
19352 V2VerifiedHead {
19353 requested: TEST_BRAIN_ID.to_string(),
19354 brain_id: TEST_BRAIN_ID.to_string(),
19355 view_kind: "scoped".to_string(),
19356 view_revision: revision.to_string(),
19357 control_revision: revision.to_string(),
19358 identity: V2HeadIdentity {
19359 custody: "hub".to_string(),
19360 fingerprint: "test".to_string(),
19361 public_key_spki: "test".to_string(),
19362 previous: Vec::new(),
19363 rotations: Vec::new(),
19364 },
19365 pointer: None,
19366 trust: TrustState {
19367 v: 2,
19368 origin: "https://hub.example".to_string(),
19369 requested: TEST_BRAIN_ID.to_string(),
19370 brain: TEST_BRAIN_ID.to_string(),
19371 home: None,
19372 anchor: "ed25519:test".to_string(),
19373 current: "ed25519:test".to_string(),
19374 head_seq: 0,
19375 feed_hash: None,
19376 rotations: Vec::new(),
19377 hub_signer: None,
19378 protocol_profile: Some("link-v2".to_string()),
19379 },
19380 alias: None,
19381 }
19382 }
19383
19384 #[test]
19385 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
19386 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
19387 assert!(accepted_as_v2(&trust));
19388
19389 trust.protocol_profile = None;
19390 trust.hub_signer = Some("ed25519:hub".to_string());
19391 assert!(accepted_as_v2(&trust));
19392
19393 trust.hub_signer = None;
19394 assert!(!accepted_as_v2(&trust));
19395 }
19396
19397 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
19398 V2SyncBaseline {
19399 v: 2,
19400 origin: "https://hub.example".to_string(),
19401 brain: TEST_BRAIN_ID.to_string(),
19402 checkout_id: Some("c".repeat(64)),
19403 head_seq: Some(0),
19404 commit_hash: None,
19405 content_root: None,
19406 asset_root: None,
19407 assets: std::collections::BTreeMap::new(),
19408 view_kind: Some("scoped".to_string()),
19409 view_revision: Some(revision.to_string()),
19410 control_revision: Some(revision.to_string()),
19411 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
19412 files: std::collections::BTreeMap::new(),
19413 scan_cache: std::collections::BTreeMap::new(),
19414 local_policy_digest: None,
19415 local_eligibility: std::collections::BTreeMap::new(),
19416 remote_copy_remains: std::collections::BTreeMap::new(),
19417 }
19418 }
19419
19420 #[test]
19421 fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
19422 let cfg = test_hub_config(
19423 "https://hub.example".to_string(),
19424 tempfile::tempdir().unwrap().keep(),
19425 );
19426 let mut baseline = scoped_test_baseline(&"a".repeat(64));
19427 baseline.assets.insert(
19428 "assets/archive.bin".to_string(),
19429 V2BaselineAsset {
19430 blob_sha256: "b".repeat(64),
19431 bytes: MAX_STORE_BYTES + 1,
19432 media_type: "application/octet-stream".to_string(),
19433 wrappers: vec!["records/archive.md".to_string()],
19434 required: true,
19435 disposition: "hosted".to_string(),
19436 leaf_hash: "c".repeat(64),
19437 },
19438 );
19439
19440 let accepted = serde_json::to_vec(&baseline).unwrap();
19441 assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
19442
19443 baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
19444 let refused = serde_json::to_vec(&baseline).unwrap();
19445 assert!(matches!(
19446 parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
19447 Err(LinkError::InvalidFeed { .. })
19448 ));
19449 }
19450
19451 #[test]
19452 fn v2_local_view_keeps_markdown_assets_in_content_but_excludes_binary_assets() {
19453 let directory = tempfile::tempdir().unwrap();
19454 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
19455 std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
19456 std::fs::write(
19457 directory.path().join("DB.md"),
19458 b"---\nname: Dual plane test\n---\n",
19459 )
19460 .unwrap();
19461 let markdown = b"---\ntype: note\n---\nintegrity tracked\n";
19462 let binary = b"pdf bytes";
19463 std::fs::write(directory.path().join("records/notes/a.md"), markdown).unwrap();
19464 std::fs::write(directory.path().join("sources/files/a.pdf"), binary).unwrap();
19465 let store = Store::open_strict(directory.path()).unwrap();
19466 crate::assets::write_manifest(
19467 &store,
19468 &[
19469 crate::AssetRecord {
19470 path: "records/notes/a.md".to_string(),
19471 sha256: content_sha256(markdown),
19472 bytes: markdown.len() as u64,
19473 media_type: "text/markdown".to_string(),
19474 wrappers: vec!["records/notes/a.md".to_string()],
19475 required: true,
19476 },
19477 crate::AssetRecord {
19478 path: "sources/files/a.pdf".to_string(),
19479 sha256: content_sha256(binary),
19480 bytes: binary.len() as u64,
19481 media_type: "application/pdf".to_string(),
19482 wrappers: vec!["records/notes/a.md".to_string()],
19483 required: true,
19484 },
19485 ],
19486 )
19487 .unwrap();
19488
19489 let view = v2_local_files(&store).unwrap();
19490 assert_eq!(
19491 view.riding.get("records/notes/a.md"),
19492 Some(&(content_sha256(markdown), markdown.len() as u64))
19493 );
19494 assert!(!view.riding.contains_key("sources/files/a.pdf"));
19495 }
19496
19497 #[test]
19498 fn v2_markdown_asset_binding_requires_identical_cross_root_state() {
19499 let path = "sources/notes/a.md".to_string();
19500 let hash = "a".repeat(64);
19501 let mut content = std::collections::BTreeMap::from([(
19502 path.clone(),
19503 V2BaselineFile {
19504 sha256: hash.clone(),
19505 bytes: 7,
19506 proof: None,
19507 },
19508 )]);
19509 let mut assets = std::collections::BTreeMap::from([(
19510 path.clone(),
19511 V2BaselineAsset {
19512 blob_sha256: hash,
19513 bytes: 7,
19514 media_type: "text/markdown".to_string(),
19515 wrappers: vec![path.clone()],
19516 required: true,
19517 disposition: "hosted".to_string(),
19518 leaf_hash: "b".repeat(64),
19519 },
19520 )]);
19521 assert!(verify_v2_markdown_asset_content_bindings(&content, &assets).is_ok());
19522
19523 content.get_mut(&path).unwrap().bytes = 8;
19524 assert!(verify_v2_markdown_asset_content_bindings(&content, &assets).is_err());
19525 content.remove(&path);
19526 assets.get_mut(&path).unwrap().disposition = "withheld".to_string();
19527 assert!(verify_v2_markdown_asset_content_bindings(&content, &assets).is_ok());
19528 content.insert(
19529 path,
19530 V2BaselineFile {
19531 sha256: "a".repeat(64),
19532 bytes: 7,
19533 proof: None,
19534 },
19535 );
19536 assert!(verify_v2_markdown_asset_content_bindings(&content, &assets).is_err());
19537 }
19538
19539 #[test]
19540 fn v2_markdown_asset_writes_use_the_typed_dual_plane_operation() {
19541 let path = "sources/notes/a.md";
19542 let assets = std::collections::BTreeMap::from([(
19543 path.to_string(),
19544 crate::AssetRecord {
19545 path: path.to_string(),
19546 sha256: "a".repeat(64),
19547 bytes: 7,
19548 media_type: "text/markdown".to_string(),
19549 wrappers: vec![path.to_string()],
19550 required: true,
19551 },
19552 )]);
19553 assert_eq!(
19554 v2_content_put_operation_kind(path, &assets),
19555 "put_asset_content"
19556 );
19557 assert_eq!(
19558 v2_content_put_operation_kind("records/ordinary.md", &assets),
19559 "put"
19560 );
19561 assert!(v2_withdrawal_includes_content(path, &assets));
19562 let mut binary_assets = assets.clone();
19563 binary_assets.insert(
19564 "sources/files/a.pdf".to_string(),
19565 crate::AssetRecord {
19566 path: "sources/files/a.pdf".to_string(),
19567 sha256: "d".repeat(64),
19568 bytes: 3,
19569 media_type: "application/pdf".to_string(),
19570 wrappers: vec![path.to_string()],
19571 required: true,
19572 },
19573 );
19574 assert!(!v2_withdrawal_includes_content(
19575 "sources/files/a.pdf",
19576 &binary_assets
19577 ));
19578
19579 let hash = "b".repeat(64);
19580 let operations = vec![json!({
19581 "op": "put_asset_content",
19582 "path": path,
19583 "expected": { "kind": "absent" },
19584 "blob": hash,
19585 "bytes": 11,
19586 })];
19587 let mut content = std::collections::BTreeMap::new();
19588 let mut remote_assets = std::collections::BTreeMap::new();
19589 apply_generated_v2_operations(&operations, &assets, &mut content, &mut remote_assets)
19590 .unwrap();
19591 assert_eq!(content.get(path).unwrap().sha256, "b".repeat(64));
19592 }
19593
19594 #[cfg(unix)]
19595 #[test]
19596 fn v2_stat_cache_waits_out_racy_files_and_binds_file_identity() {
19597 use std::os::unix::fs::MetadataExt as _;
19598
19599 let directory = tempfile::tempdir().unwrap();
19600 let first = directory.path().join("first.md");
19601 let second = directory.path().join("second.md");
19602 std::fs::write(&first, b"same").unwrap();
19603 std::fs::write(&second, b"same").unwrap();
19604 let first = std::fs::metadata(first).unwrap();
19605 let second = std::fs::metadata(second).unwrap();
19606 let observed_ns = |metadata: &std::fs::Metadata| {
19607 let mtime =
19608 i128::from(metadata.mtime()) * 1_000_000_000 + i128::from(metadata.mtime_nsec());
19609 let ctime =
19610 i128::from(metadata.ctime()) * 1_000_000_000 + i128::from(metadata.ctime_nsec());
19611 mtime.max(ctime)
19612 };
19613
19614 assert!(v2_scan_fingerprint_at(&first, observed_ns(&first) + 1_000_000_000).is_none());
19615 let first_fingerprint =
19616 v2_scan_fingerprint_at(&first, observed_ns(&first) + 3_000_000_000).unwrap();
19617 let second_fingerprint =
19618 v2_scan_fingerprint_at(&second, observed_ns(&second) + 3_000_000_000).unwrap();
19619 assert_ne!(first_fingerprint, second_fingerprint);
19620 }
19621
19622 #[test]
19623 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
19624 let directory = tempfile::tempdir().unwrap();
19625 std::fs::write(
19626 directory.path().join("DB.md"),
19627 scoped_projection_bytes(TEST_BRAIN_ID),
19628 )
19629 .unwrap();
19630 let store = Store::open_strict(directory.path()).unwrap();
19631 let head = scoped_test_head(&"a".repeat(64));
19632 let baseline = scoped_test_baseline(&"a".repeat(64));
19633 let mut view = v2_local_files(&store).unwrap();
19634 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
19635 assert!(!view.riding.contains_key("DB.md"));
19636 assert!(!view.eligibility.contains_key("DB.md"));
19637 }
19638
19639 #[test]
19640 fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
19641 let directory = tempfile::tempdir().unwrap();
19642 std::fs::write(
19643 directory.path().join("DB.md"),
19644 scoped_projection_bytes(TEST_BRAIN_ID),
19645 )
19646 .unwrap();
19647 let store = Store::open_strict(directory.path()).unwrap();
19648 let head = scoped_test_head(&"a".repeat(64));
19649 let baseline = scoped_test_baseline(&"a".repeat(64));
19650
19651 let mut carried = v2_local_files(&store).unwrap();
19652 remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
19653 let handed_off =
19654 local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
19655 assert!(!handed_off.riding.contains_key("DB.md"));
19656
19657 let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
19658 assert!(!freshly_scanned.riding.contains_key("DB.md"));
19659
19660 std::fs::write(
19661 directory.path().join("DB.md"),
19662 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
19663 )
19664 .unwrap();
19665 let tampered = Store::open_strict(directory.path()).unwrap();
19666 assert!(matches!(
19667 local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
19668 Err(LinkError::ScopedProjectionModified)
19669 ));
19670 }
19671
19672 #[test]
19673 fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
19674 let directory = tempfile::tempdir().unwrap();
19675 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
19676 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
19677 std::fs::write(
19678 directory.path().join("DB.md"),
19679 b"---\nname: Kept home test\n---\n",
19680 )
19681 .unwrap();
19682 std::fs::write(
19683 directory.path().join("records/notes/a.md"),
19684 b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
19685 )
19686 .unwrap();
19687 std::fs::write(
19688 directory.path().join("sources/private/secret.md"),
19689 b"---\ntype: note\n---\nlocal only\n",
19690 )
19691 .unwrap();
19692 std::fs::write(
19693 directory.path().join("sources/private/unlinked.md"),
19694 b"---\ntype: note\n---\nnot disclosed\n",
19695 )
19696 .unwrap();
19697 std::fs::write(
19698 directory.path().join(".sevralocal"),
19699 b"sources/private/**\n",
19700 )
19701 .unwrap();
19702
19703 let store = Store::open_strict(directory.path()).unwrap();
19704 let view = v2_local_files(&store).unwrap();
19705 assert!(!view.riding.contains_key("sources/private/secret.md"));
19706 assert_eq!(
19707 view.withheld_links,
19708 vec![V2WithheldLink {
19709 source: "records/notes/a.md".to_string(),
19710 target: "sources/private/secret.md".to_string(),
19711 }]
19712 );
19713 }
19714
19715 #[test]
19716 fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
19717 let directory = tempfile::tempdir().unwrap();
19722 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
19723 std::fs::write(
19724 directory.path().join("DB.md"),
19725 b"---\nname: Restored export\n---\n",
19726 )
19727 .unwrap();
19728 std::fs::write(
19729 directory.path().join("records/notes/a.md"),
19730 b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
19731 )
19732 .unwrap();
19733 std::fs::write(
19734 directory.path().join(".sevralocal"),
19735 b"sources/private/**\n",
19736 )
19737 .unwrap();
19738
19739 let store = Store::open_strict(directory.path()).unwrap();
19740 let view = v2_local_files(&store).unwrap();
19741 assert_eq!(
19742 view.withheld_links,
19743 vec![V2WithheldLink {
19744 source: "records/notes/a.md".to_string(),
19745 target: "sources/private/absent.md".to_string(),
19746 }]
19747 );
19748 std::fs::write(
19750 directory.path().join("records/notes/b.md"),
19751 b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
19752 )
19753 .unwrap();
19754 let store = Store::open_strict(directory.path()).unwrap();
19755 let view = v2_local_files(&store).unwrap();
19756 assert!(
19757 !view
19758 .withheld_links
19759 .iter()
19760 .any(|link| link.target == "records/notes/nowhere.md"),
19761 "an unclaimed dangling target must not be declared withheld"
19762 );
19763 }
19764
19765 #[test]
19766 fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
19767 let directory = tempfile::tempdir().unwrap();
19768 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
19769 std::fs::write(
19770 directory.path().join("DB.md"),
19771 b"---\nname: Withdrawal test\n---\n",
19772 )
19773 .unwrap();
19774 let source = b"---\ntype: note\n---\nlocal evidence\n";
19775 std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
19776 std::fs::write(
19777 directory.path().join(".sevralocal"),
19778 b"sources/private/**\n",
19779 )
19780 .unwrap();
19781 let store = Store::open_strict(directory.path()).unwrap();
19782 let view = v2_local_files(&store).unwrap();
19783 let mut remote = std::collections::BTreeMap::new();
19784 remote.insert(
19785 "sources/private/evidence.md".to_string(),
19786 V2BaselineFile {
19787 sha256: content_sha256(source),
19788 bytes: source.len() as u64,
19789 proof: None,
19790 },
19791 );
19792 assert_eq!(
19793 v2_content_withdrawal_operation(
19794 &store,
19795 &view,
19796 &remote,
19797 "sources/private/evidence.md",
19798 "approved retention change",
19799 )
19800 .unwrap(),
19801 json!({
19802 "op": "withdraw_from_hosting",
19803 "path": "sources/private/evidence.md",
19804 "expected": { "kind": "blob", "hash": content_sha256(source) },
19805 "reason": "approved retention change",
19806 })
19807 );
19808
19809 std::fs::write(
19810 directory.path().join("sources/private/evidence.md"),
19811 b"changed after review",
19812 )
19813 .unwrap();
19814 assert!(matches!(
19815 v2_content_withdrawal_operation(
19816 &store,
19817 &view,
19818 &remote,
19819 "sources/private/evidence.md",
19820 "approved retention change",
19821 ),
19822 Err(LinkError::InvalidPack { .. })
19823 ));
19824 }
19825
19826 #[test]
19827 fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
19828 let directory = tempfile::tempdir().unwrap();
19829 std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
19830 std::fs::write(
19831 directory.path().join("DB.md"),
19832 b"---\nname: Asset withdrawal test\n---\n",
19833 )
19834 .unwrap();
19835 let bytes = b"private binary";
19836 std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
19837 std::fs::write(
19838 directory.path().join(".sevralocal"),
19839 b"sources/files/private.pdf\n",
19840 )
19841 .unwrap();
19842 let store = Store::open_strict(directory.path()).unwrap();
19843 let view = v2_local_files(&store).unwrap();
19844 let local = crate::AssetRecord {
19845 path: "sources/files/private.pdf".to_string(),
19846 sha256: content_sha256(bytes),
19847 bytes: bytes.len() as u64,
19848 media_type: "application/pdf".to_string(),
19849 wrappers: vec![
19850 "sources/files/private.md".to_string(),
19851 "sources/redacted/private.md".to_string(),
19852 ],
19853 required: false,
19854 };
19855 let current = V2BaselineAsset {
19856 blob_sha256: local.sha256.clone(),
19857 bytes: local.bytes,
19858 media_type: local.media_type.clone(),
19859 wrappers: vec!["sources/files/private.md".to_string()],
19860 required: true,
19861 disposition: "hosted".to_string(),
19862 leaf_hash: "d".repeat(64),
19863 };
19864 assert_eq!(
19865 v2_asset_withdrawal_operation(
19866 &store,
19867 &view,
19868 &local.path,
19869 &local,
19870 ¤t,
19871 "approved retention change",
19872 )
19873 .unwrap(),
19874 json!({
19875 "op": "asset_withdraw",
19876 "path": local.path,
19877 "expected": { "kind": "asset", "hash": "d".repeat(64) },
19878 "asset": {
19879 "blob_sha256": local.sha256.clone(),
19880 "bytes": local.bytes,
19881 "media_type": local.media_type.clone(),
19882 "wrappers": local.wrappers.clone(),
19883 "required": false,
19884 "disposition": "withheld",
19885 },
19886 "reason": "approved retention change",
19887 })
19888 );
19889
19890 let mut mismatched = current.clone();
19891 mismatched.blob_sha256 = "f".repeat(64);
19892 assert!(matches!(
19893 v2_asset_withdrawal_operation(
19894 &store,
19895 &view,
19896 &local.path,
19897 &local,
19898 &mismatched,
19899 "approved retention change",
19900 ),
19901 Err(LinkError::InvalidPack { .. })
19902 ));
19903 }
19904
19905 #[test]
19906 fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
19907 let first = v2_checkout_id(None).unwrap();
19908 assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
19909 assert_ne!(first, v2_checkout_id(None).unwrap());
19910 assert!(is_sha256(&first));
19911 }
19912
19913 #[test]
19914 fn moved_checkout_relocates_its_path_bound_baseline_without_rehash_ambiguity() {
19915 let sandbox = tempfile::tempdir().unwrap();
19916 let stage_root = sandbox.path().join("stage");
19917 let live_root = sandbox.path().join("live");
19918 let from = stage_root.join("db");
19919 let to = live_root.join("db");
19920 std::fs::create_dir_all(from.join("records/items")).unwrap();
19921 std::fs::write(
19922 from.join("DB.md"),
19923 b"---\ntype: db-md\nscope: company\nowner: test\n---\n",
19924 )
19925 .unwrap();
19926 std::fs::write(
19927 from.join("records/items/example.md"),
19928 b"---\ntype: item\n---\n\n# Example\n",
19929 )
19930 .unwrap();
19931 let cfg = test_hub_config(
19932 "https://hub.example".to_string(),
19933 sandbox.path().join("state"),
19934 );
19935 let store = Store::open_strict(&from).unwrap();
19936 let local = v2_local_files(&store).unwrap();
19937 let files = local
19938 .riding
19939 .iter()
19940 .map(|(path, (sha256, bytes))| {
19941 (
19942 path.clone(),
19943 V2BaselineFile {
19944 sha256: sha256.clone(),
19945 bytes: *bytes,
19946 proof: None,
19947 },
19948 )
19949 })
19950 .collect();
19951 let baseline = V2SyncBaseline {
19952 v: 2,
19953 origin: "https://hub.example".to_string(),
19954 brain: TEST_BRAIN_ID.to_string(),
19955 checkout_id: Some("c".repeat(64)),
19956 head_seq: Some(7),
19957 commit_hash: Some("a".repeat(64)),
19958 content_root: Some("b".repeat(64)),
19959 asset_root: None,
19960 assets: std::collections::BTreeMap::new(),
19961 view_kind: Some("full".to_string()),
19962 view_revision: Some("d".repeat(64)),
19963 control_revision: Some("e".repeat(64)),
19964 projection_sha256: None,
19965 files,
19966 scan_cache: local.scan_cache.clone(),
19967 local_policy_digest: Some(local.policy.digest.clone()),
19968 local_eligibility: local.eligibility.clone(),
19969 remote_copy_remains: std::collections::BTreeMap::new(),
19970 };
19971 save_v2_baseline(&cfg, TEST_BRAIN_ID, &from, &baseline).unwrap();
19972 std::fs::rename(&stage_root, &live_root).unwrap();
19973
19974 let first = relocate_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from, &to).unwrap();
19975 assert_eq!(first.get("moved").and_then(Value::as_bool), Some(true));
19976 assert!(!has_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from).unwrap());
19977 assert!(has_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &to).unwrap());
19978
19979 let retry = relocate_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from, &to).unwrap();
19980 assert_eq!(retry.get("moved").and_then(Value::as_bool), Some(false));
19981 }
19982
19983 #[test]
19984 fn scoped_projection_edit_and_scope_transition_fail_closed() {
19985 let directory = tempfile::tempdir().unwrap();
19986 std::fs::write(
19987 directory.path().join("DB.md"),
19988 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
19989 )
19990 .unwrap();
19991 let store = Store::open_strict(directory.path()).unwrap();
19992 let head = scoped_test_head(&"a".repeat(64));
19993 let baseline = scoped_test_baseline(&"a".repeat(64));
19994 let mut view = v2_local_files(&store).unwrap();
19995 assert!(matches!(
19996 remove_scoped_projection(&head, Some(&baseline), &mut view),
19997 Err(LinkError::ScopedProjectionModified)
19998 ));
19999
20000 let changed = scoped_test_head(&"b".repeat(64));
20001 assert!(matches!(
20002 ensure_v2_view_compatible(&changed, Some(&baseline)),
20003 Err(LinkError::ScopedViewChanged)
20004 ));
20005
20006 let mut same_view_new_control = head.clone();
20007 same_view_new_control.control_revision = "c".repeat(64);
20008 assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
20009 assert!(!same_v2_head(&head, &same_view_new_control));
20010 }
20011
20012 #[test]
20013 fn verified_baseline_reuse_requires_exact_content_assets_view_and_authority() {
20014 let mut head = scoped_test_head(&"a".repeat(64));
20015 head.control_revision = "b".repeat(64);
20016 head.pointer = Some(V2PointerBody {
20017 v: 2,
20018 brain: TEST_BRAIN_ID.to_string(),
20019 seq: 7,
20020 commit_hash: "c".repeat(64),
20021 feed_hash: "d".repeat(64),
20022 content_root: Some("e".repeat(64)),
20023 asset_root: Some("f".repeat(64)),
20024 materializer: "dbmd-projection-v1".to_string(),
20025 signer_epoch: 1,
20026 control_revision: head.control_revision.clone(),
20027 backup_preparation: "0".repeat(64),
20028 prior_pointer_hash: Some("1".repeat(64)),
20029 signed_at: "2026-08-24T00:00:00.000Z".to_string(),
20030 });
20031 let mut baseline = scoped_test_baseline(&head.view_revision);
20032 baseline.head_seq = Some(7);
20033 baseline.commit_hash = Some("c".repeat(64));
20034 baseline.content_root = Some("e".repeat(64));
20035 baseline.asset_root = Some("f".repeat(64));
20036 baseline.control_revision = Some(head.control_revision.clone());
20037 assert!(v2_baseline_matches_head(&head, &baseline));
20038
20039 let mut changed = baseline.clone();
20040 changed.head_seq = Some(8);
20041 assert!(!v2_baseline_matches_head(&head, &changed));
20042 let mut changed = baseline.clone();
20043 changed.commit_hash = Some("2".repeat(64));
20044 assert!(!v2_baseline_matches_head(&head, &changed));
20045 let mut changed = baseline.clone();
20046 changed.content_root = Some("3".repeat(64));
20047 assert!(!v2_baseline_matches_head(&head, &changed));
20048 let mut changed = baseline.clone();
20049 changed.asset_root = Some("4".repeat(64));
20050 assert!(!v2_baseline_matches_head(&head, &changed));
20051 let mut changed = baseline.clone();
20052 changed.view_revision = Some("5".repeat(64));
20053 assert!(!v2_baseline_matches_head(&head, &changed));
20054 let mut changed = baseline.clone();
20055 changed.control_revision = Some("6".repeat(64));
20056 assert!(!v2_baseline_matches_head(&head, &changed));
20057
20058 let mut changed_head = head.clone();
20059 changed_head.view_kind = "full".to_string();
20060 assert!(!v2_baseline_matches_head(&changed_head, &baseline));
20061 }
20062
20063 #[test]
20064 fn legacy_baseline_without_authority_revision_refreshes_before_reuse() {
20065 let sandbox = tempfile::tempdir().unwrap();
20066 let cfg = test_hub_config(
20067 "https://hub.example".to_string(),
20068 sandbox.path().to_path_buf(),
20069 );
20070 let head = scoped_test_head(&"a".repeat(64));
20071 let baseline = scoped_test_baseline(&head.view_revision);
20072 let mut encoded = serde_json::to_value(&baseline).unwrap();
20073 encoded.as_object_mut().unwrap().remove("control_revision");
20074 let parsed =
20075 parse_v2_baseline(&cfg, TEST_BRAIN_ID, &serde_json::to_vec(&encoded).unwrap()).unwrap();
20076 assert!(parsed.control_revision.is_none());
20077 assert!(!v2_baseline_matches_head(&head, &parsed));
20078 }
20079
20080 #[test]
20081 fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
20082 let scoped = scoped_test_head(&"a".repeat(64));
20083 let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
20084 assert!(matches!(
20085 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
20086 Err(LinkError::ScopedProjectionModified)
20087 ));
20088
20089 let mut full = scoped.clone();
20090 full.view_kind = "full".to_string();
20091 let mut full_baseline = scoped_baseline.clone();
20092 full_baseline.view_kind = Some("full".to_string());
20093 full_baseline.projection_sha256 = None;
20094 assert!(matches!(
20095 ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
20096 Err(LinkError::InvalidPack { .. })
20097 ));
20098
20099 assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
20100 assert!(
20101 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
20102 );
20103 }
20104
20105 #[test]
20106 fn scoped_view_metadata_is_explicitly_non_authoritative() {
20107 let head = scoped_test_head(&"a".repeat(64));
20108 let value: Value =
20109 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
20110 assert_eq!(value["kind"], "link.md-scoped-view");
20111 assert_eq!(value["authoritative"], false);
20112 assert_eq!(value["visible_files"], 7);
20113 assert_eq!(value["brain"], TEST_BRAIN_ID);
20114 }
20115
20116 #[test]
20117 fn local_scoped_marker_requires_the_exact_generated_projection() {
20118 let directory = tempfile::tempdir().unwrap();
20119 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
20120 std::fs::write(
20121 directory.path().join("DB.md"),
20122 scoped_projection_bytes(TEST_BRAIN_ID),
20123 )
20124 .unwrap();
20125 let head = scoped_test_head(&"a".repeat(64));
20126 std::fs::write(
20127 directory.path().join(".dbmd/view.json"),
20128 scoped_view_metadata(&head, 0).unwrap(),
20129 )
20130 .unwrap();
20131 let store = Store::open_strict(directory.path()).unwrap();
20132 assert!(has_verified_local_scoped_view(&store));
20133
20134 std::fs::write(
20135 directory.path().join("DB.md"),
20136 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
20137 )
20138 .unwrap();
20139 let altered = Store::open_strict(directory.path()).unwrap();
20140 assert!(!has_verified_local_scoped_view(&altered));
20141 }
20142
20143 fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
20144 use ring::signature::KeyPair as _;
20145
20146 let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
20147 let rng = ring::rand::SystemRandom::new();
20148 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
20149 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
20150 let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
20151 let public_key = URL_SAFE_NO_PAD.encode(&public_der);
20152 let fingerprint = format!("{:x}", Sha256::digest(&public_der));
20153 let blob = b"new";
20154 let blob_hash = content_sha256(blob);
20155 let changes = json!({
20156 "mutation_id": "sync:proposal-fixture",
20157 "operations": [{
20158 "blob": blob_hash,
20159 "bytes": blob.len(),
20160 "expected": null,
20161 "op": "put",
20162 "path": "records/new.md",
20163 }],
20164 "reason": "fixture",
20165 "v": 2,
20166 });
20167 let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
20168 let changes_base64 = STANDARD.encode(&changes_bytes);
20169 let descriptor = json!({
20170 "base": null,
20171 "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
20172 "changes_base64": changes_base64,
20173 "rebase": "strict",
20174 "v": 2,
20175 });
20176 let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
20177 let payload_hash = "b".repeat(64);
20178 let submitted_at = "2026-08-19T12:00:00.000Z";
20179 let claim = json!({
20180 "actor_root": {
20181 "actor_class": "foreign_key",
20182 "credential": "ed25519:fixture",
20183 "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
20184 "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
20185 "principal": "key:fixture",
20186 "role": null,
20187 },
20188 "brain": TEST_BRAIN_ID,
20189 "clear_sha256": clear_hash,
20190 "control_revision": "c".repeat(64),
20191 "mutation_id": "sync:proposal-fixture",
20192 "payload_sha256": payload_hash,
20193 "proposal_id": proposal_id,
20194 "submitted_at": submitted_at,
20195 "v": 2,
20196 });
20197 let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
20198 let envelope = json!({
20199 "claim": claim,
20200 "fingerprint": fingerprint,
20201 "public_key": public_key,
20202 "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
20203 });
20204 let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
20205 let submission_hash =
20206 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
20207 let mut head = scoped_test_head(&"c".repeat(64));
20208 head.view_kind = "full".to_string();
20209 head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
20210 let value = json!({
20211 "proposal": {
20212 "base": null,
20213 "blobs": [{
20214 "bytes": blob.len(),
20215 "endpoint": format!(
20216 "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
20217 ),
20218 "sha256": blob_hash,
20219 }],
20220 "changes_base64": changes_base64,
20221 "clear_sha256": clear_hash,
20222 "expires_at": "2026-08-26T12:00:00.000Z",
20223 "id": proposal_id,
20224 "payload_sha256": payload_hash,
20225 "proposer": { "class": "foreign_key" },
20226 "rebase": "strict",
20227 "state": "pending",
20228 "submission_claim_base64": STANDARD.encode(envelope_bytes),
20229 "submission_claim_sha256": submission_hash,
20230 "submitted_at": submitted_at,
20231 },
20232 "v": 2,
20233 });
20234 (head, proposal_id, value)
20235 }
20236
20237 #[test]
20238 fn v2_proposal_verifier_accepts_exact_signed_payload() {
20239 let (head, proposal_id, value) = signed_proposal_fixture();
20240 let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
20241 assert_eq!(verified.blobs.len(), 1);
20242 assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
20243 }
20244
20245 #[test]
20246 fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
20247 let (head, proposal_id, value) = signed_proposal_fixture();
20248
20249 let mut changed = value.clone();
20250 changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
20251 assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
20252
20253 let mut redirected = value.clone();
20254 redirected["proposal"]["blobs"][0]["endpoint"] =
20255 Value::String("https://attacker.example/blob".to_string());
20256 assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
20257
20258 let mut forged = value;
20259 let encoded = forged["proposal"]["submission_claim_base64"]
20260 .as_str()
20261 .unwrap();
20262 let mut envelope: Value =
20263 serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
20264 envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
20265 let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
20266 forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
20267 forged["proposal"]["submission_claim_sha256"] = Value::String(
20268 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
20269 );
20270 assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
20271 }
20272
20273 #[cfg(unix)]
20274 #[test]
20275 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
20276 let sandbox = tempfile::tempdir().unwrap();
20277 let destination = sandbox.path().join("brain");
20278 let entries = vec![
20279 (
20280 "DB.md".to_string(),
20281 scoped_projection_bytes(TEST_BRAIN_ID),
20282 ),
20283 (
20284 "records/contacts/a.md".to_string(),
20285 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
20286 .to_vec(),
20287 ),
20288 ];
20289 install_pulled_delta(&destination, &entries, &[], true).unwrap();
20290 assert!(destination.join("index.md").is_file());
20291 assert!(destination.join("records/index.md").is_file());
20292 assert!(destination.join("records/contacts/index.md").is_file());
20293 assert!(destination.join("records/contacts/index.jsonl").is_file());
20294 }
20295
20296 #[cfg(unix)]
20297 #[test]
20298 fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
20299 let sandbox = tempfile::tempdir().unwrap();
20300 let destination = sandbox.path().join("brain");
20301 let cache = sandbox.path().join("cache");
20302 std::fs::create_dir(&cache).unwrap();
20303 let db = scoped_projection_bytes(TEST_BRAIN_ID);
20304 let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
20305 let db_source = cache.join("db");
20306 let shared_source = cache.join("shared");
20307 crate::fsx::write_atomic(&db_source, &db).unwrap();
20308 crate::fsx::write_atomic(&shared_source, shared).unwrap();
20309 let mut entries = vec![V2StagedFile {
20310 path: "DB.md".to_string(),
20311 source: db_source,
20312 sha256: content_sha256(&db),
20313 bytes: db.len() as u64,
20314 }];
20315 for index in 0..512 {
20316 entries.push(V2StagedFile {
20317 path: format!("records/items/{index:05}.md"),
20318 source: shared_source.clone(),
20319 sha256: content_sha256(shared),
20320 bytes: shared.len() as u64,
20321 });
20322 }
20323 install_pulled_delta_sources(
20324 &destination,
20325 &entries,
20326 &[],
20327 false,
20328 None,
20329 &scoped_test_head(&"c".repeat(64)),
20330 )
20331 .unwrap();
20332 assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
20333 for index in 0..512 {
20334 assert_eq!(
20335 std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
20336 shared
20337 );
20338 }
20339 assert!(
20340 std::fs::read_dir(sandbox.path())
20341 .unwrap()
20342 .all(|entry| !entry
20343 .unwrap()
20344 .file_name()
20345 .to_string_lossy()
20346 .contains("pull-stage")),
20347 "the private stage must be atomically installed or removed"
20348 );
20349 }
20350
20351 #[cfg(unix)]
20352 #[test]
20353 fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
20354 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
20355
20356 let sandbox = tempfile::tempdir().unwrap();
20357 let root = sandbox.path().join("brain");
20358 std::fs::create_dir_all(root.join("records/items")).unwrap();
20359 let db = scoped_projection_bytes(TEST_BRAIN_ID);
20360 let old = b"---\ntype: note\n---\n\nold\n";
20361 let new = b"---\ntype: note\n---\n\nnew\n";
20362 let removed = b"---\ntype: note\n---\n\nremove me\n";
20363 std::fs::write(root.join("DB.md"), &db).unwrap();
20364 std::fs::write(root.join("records/items/change.md"), old).unwrap();
20365 std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
20366 for index in 0..512 {
20367 std::fs::write(
20368 root.join(format!("records/items/untouched-{index:04}.md")),
20369 old,
20370 )
20371 .unwrap();
20372 }
20373 let untouched = root.join("records/items/untouched-0256.md");
20374 let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
20375 let source = sandbox.path().join("changed-source");
20376 crate::fsx::write_atomic(&source, new).unwrap();
20377 let same_source = sandbox.path().join("unchanged-source");
20378 crate::fsx::write_atomic(&same_source, old).unwrap();
20379 let same_entry = V2StagedFile {
20380 path: "records/items/change.md".to_string(),
20381 source: same_source,
20382 sha256: content_sha256(old),
20383 bytes: old.len() as u64,
20384 };
20385 let entry = V2StagedFile {
20386 path: "records/items/change.md".to_string(),
20387 source,
20388 sha256: content_sha256(new),
20389 bytes: new.len() as u64,
20390 };
20391 let head = scoped_test_head(&"c".repeat(64));
20392
20393 install_established_v2_delta(
20397 Store::open_strict(&root).unwrap(),
20398 &[same_entry],
20399 &["records/items/already-absent.md".to_string()],
20400 true,
20401 None,
20402 &head,
20403 )
20404 .unwrap();
20405 assert_eq!(
20406 std::fs::metadata(&untouched).unwrap().ino(),
20407 untouched_inode
20408 );
20409 assert!(!root.join(V2_PULL_JOURNAL).exists());
20410
20411 install_established_v2_delta(
20412 Store::open_strict(&root).unwrap(),
20413 &[entry],
20414 &["records/items/delete.md".to_string()],
20415 false,
20416 None,
20417 &head,
20418 )
20419 .unwrap();
20420 assert_eq!(
20421 std::fs::read(root.join("records/items/change.md")).unwrap(),
20422 new
20423 );
20424 assert!(!root.join("records/items/delete.md").exists());
20425 assert_eq!(
20426 std::fs::metadata(&untouched).unwrap().ino(),
20427 untouched_inode
20428 );
20429 assert!(root.join(V2_PULL_JOURNAL).is_file());
20430 assert_eq!(
20431 std::fs::metadata(root.join(V2_PULL_JOURNAL))
20432 .unwrap()
20433 .permissions()
20434 .mode()
20435 & 0o777,
20436 0o600
20437 );
20438 let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
20439 .unwrap()
20440 .unwrap();
20441 assert_eq!(
20442 std::fs::metadata(root.join(&journal.backup_dir))
20443 .unwrap()
20444 .permissions()
20445 .mode()
20446 & 0o777,
20447 0o700
20448 );
20449 for entry in &journal.entries {
20450 if let Some(backup) = &entry.backup {
20451 assert_eq!(
20452 std::fs::metadata(root.join(&journal.backup_dir).join(backup))
20453 .unwrap()
20454 .permissions()
20455 .mode()
20456 & 0o777,
20457 0o600
20458 );
20459 }
20460 }
20461
20462 let cfg = test_hub_config(
20463 "https://example.test".to_string(),
20464 sandbox.path().join("state"),
20465 );
20466 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20467 assert_eq!(
20468 std::fs::read(root.join("records/items/change.md")).unwrap(),
20469 old
20470 );
20471 assert_eq!(
20472 std::fs::read(root.join("records/items/delete.md")).unwrap(),
20473 removed
20474 );
20475 assert_eq!(
20476 std::fs::metadata(&untouched).unwrap().ino(),
20477 untouched_inode
20478 );
20479 assert!(!root.join(V2_PULL_JOURNAL).exists());
20480 }
20481
20482 #[test]
20483 fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
20484 let body = b"bounded bytes";
20485 let path = "records/example.md".to_string();
20486 let file = V2BaselineFile {
20487 sha256: content_sha256(body),
20488 bytes: body.len() as u64,
20489 proof: None,
20490 };
20491 let header = serde_json::to_vec(&json!({
20492 "bytes": body.len(),
20493 "path": path,
20494 "sha256": file.sha256,
20495 "v": 2,
20496 }))
20497 .unwrap();
20498 let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
20499 stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
20500 stream.extend_from_slice(&header);
20501 stream.extend_from_slice(body);
20502 stream.extend_from_slice(&0_u32.to_be_bytes());
20503 let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
20504 assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
20505
20506 let mut tampered = stream.clone();
20507 let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
20508 tampered[body_offset] ^= 1;
20509 assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
20510
20511 let mut trailing = stream;
20512 trailing.push(0);
20513 assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
20514 }
20515
20516 #[test]
20517 fn first_checkout_resolution_does_not_recreate_the_same_conflict() {
20518 let path = "records/value.md".to_string();
20519 let mut local = std::collections::BTreeMap::new();
20520 local.insert(path.clone(), (content_sha256(b"local"), 5));
20521 let mut remote = std::collections::BTreeMap::new();
20522 remote.insert(
20523 path.clone(),
20524 V2BaselineFile {
20525 sha256: content_sha256(b"remote"),
20526 bytes: 6,
20527 proof: None,
20528 },
20529 );
20530
20531 assert_eq!(
20532 v2_initial_content_conflicts(&local, &remote, false),
20533 vec![path]
20534 );
20535 assert!(v2_initial_content_conflicts(&local, &remote, true).is_empty());
20536
20537 let mut resolution = std::collections::BTreeMap::new();
20538 resolution.insert(
20539 "records/value.md".to_string(),
20540 V2ResolutionOverride {
20541 expected_remote: Some(content_sha256(b"remote")),
20542 selected_local: Some(content_sha256(b"local")),
20543 },
20544 );
20545 assert!(v2_resolution_allows_path(
20546 Some(&resolution),
20547 "records/value.md",
20548 true
20549 ));
20550 assert!(v2_resolution_allows_path(
20551 Some(&resolution),
20552 "records/new-target.md",
20553 false
20554 ));
20555 assert!(!v2_resolution_allows_path(
20556 Some(&resolution),
20557 "records/unreviewed-remote.md",
20558 true
20559 ));
20560 }
20561
20562 #[test]
20563 fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
20564 let sandbox = tempfile::TempDir::new().unwrap();
20565 let root = sandbox.path().join("brain");
20566 std::fs::create_dir_all(&root).unwrap();
20567 std::fs::write(
20568 root.join("DB.md"),
20569 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20570 )
20571 .unwrap();
20572 let store = Store::open_strict(&root).unwrap();
20573 let incomplete = crate::ulid::mint();
20574 store
20575 .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
20576 .unwrap();
20577 let expired = crate::ulid::mint();
20578 store
20579 .create_dir_all(&v2_conflict_relative(&expired, "files"))
20580 .unwrap();
20581 let plan = V2ConflictPlan {
20582 v: 2,
20583 class: "content_resolution_required".to_string(),
20584 bundle: expired.clone(),
20585 brain: TEST_BRAIN_ID.to_string(),
20586 origin: "https://example.test".to_string(),
20587 created_unix: 0,
20588 expires_unix: 0,
20589 base_seq: None,
20590 base_commit: None,
20591 remote_seq: 0,
20592 remote_commit: None,
20593 remote_content_root: None,
20594 view_kind: "full".to_string(),
20595 view_revision: "a".repeat(64),
20596 files: vec![V2ConflictFile {
20597 path: "records/value.md".to_string(),
20598 base: V2ConflictCoordinate {
20599 sha256: None,
20600 bytes: None,
20601 file: None,
20602 },
20603 local: V2ConflictCoordinate {
20604 sha256: None,
20605 bytes: None,
20606 file: None,
20607 },
20608 remote: V2ConflictCoordinate {
20609 sha256: None,
20610 bytes: None,
20611 file: None,
20612 },
20613 }],
20614 };
20615 let mut bytes = serde_json::to_vec(&plan).unwrap();
20616 bytes.push(b'\n');
20617 store
20618 .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
20619 .unwrap();
20620
20621 let listed = sync_conflicts(&root, false, false).unwrap();
20622 assert_eq!(listed["bundles"], 2);
20623 assert_eq!(listed["pruned"], 0);
20624 let pruned = sync_conflicts(&root, true, false).unwrap();
20625 assert_eq!(pruned["bundles"], 0);
20626 assert_eq!(pruned["pruned"], 2);
20627 assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
20628 assert!(!root.join(".dbmd/conflicts").join(expired).exists());
20629 }
20630
20631 #[test]
20632 fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
20633 let sandbox = tempfile::TempDir::new().unwrap();
20634 let root = sandbox.path().join("brain");
20635 std::fs::create_dir_all(&root).unwrap();
20636 std::fs::write(
20637 root.join("DB.md"),
20638 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20639 )
20640 .unwrap();
20641 let store = Store::open_strict(&root).unwrap();
20642 let bundle = crate::ulid::mint();
20643 store
20644 .create_dir_all(&v2_conflict_relative(&bundle, "files"))
20645 .unwrap();
20646 store
20647 .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
20648 .unwrap();
20649
20650 assert!(sync_conflicts(&root, true, false).is_err());
20651 assert!(sync_conflicts(&root, false, true).is_err());
20652 let pruned = sync_conflicts(&root, true, true).unwrap();
20653 assert_eq!(pruned["pruned"], 1);
20654 assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
20655 }
20656
20657 #[test]
20658 fn ready_pull_journal_rolls_back_exact_preimages() {
20659 let sandbox = tempfile::TempDir::new().unwrap();
20660 let root = sandbox.path().join("brain");
20661 std::fs::create_dir_all(root.join("records")).unwrap();
20662 std::fs::write(
20663 root.join("DB.md"),
20664 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20665 )
20666 .unwrap();
20667 let path = "records/value.md";
20668 let old = b"---\ntype: note\n---\n\nold\n";
20669 let new = b"---\ntype: note\n---\n\nnew\n";
20670 std::fs::write(root.join(path), old).unwrap();
20671 let store = Store::open_strict(&root).unwrap();
20672 let bundle = crate::ulid::mint();
20673 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20674 store
20675 .create_private_dir_all(Path::new(&backup_dir))
20676 .unwrap();
20677 store
20678 .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
20679 .unwrap();
20680 let journal = V2PullJournal {
20681 v: 1,
20682 phase: V2PullPhase::Ready,
20683 brain: TEST_BRAIN_ID.to_string(),
20684 previous: V2PullCoordinate {
20685 head_seq: None,
20686 commit_hash: None,
20687 view_kind: None,
20688 view_revision: None,
20689 },
20690 next: V2PullCoordinate {
20691 head_seq: Some(2),
20692 commit_hash: Some("c".repeat(64)),
20693 view_kind: Some("full".to_string()),
20694 view_revision: Some("d".repeat(64)),
20695 },
20696 backup_dir: backup_dir.clone(),
20697 entries: vec![V2PullJournalEntry {
20698 path: path.to_string(),
20699 old: Some(V2PullFileCoordinate {
20700 sha256: content_sha256(old),
20701 bytes: old.len() as u64,
20702 }),
20703 new: Some(V2PullFileCoordinate {
20704 sha256: content_sha256(new),
20705 bytes: new.len() as u64,
20706 }),
20707 backup: Some("00000000".to_string()),
20708 }],
20709 };
20710 validate_v2_pull_journal(&journal).unwrap();
20711 store
20712 .write_private_atomic_new(
20713 Path::new(V2_PULL_JOURNAL),
20714 &v2_pull_journal_bytes(&journal).unwrap(),
20715 )
20716 .unwrap();
20717 store.write_atomic(Path::new(path), new).unwrap();
20718
20719 let cfg = test_hub_config(
20720 "https://example.test".to_string(),
20721 sandbox.path().join("state"),
20722 );
20723 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20724 assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
20725 assert!(!root.join(V2_PULL_JOURNAL).exists());
20726 assert!(!root.join(backup_dir).exists());
20727 }
20728
20729 #[test]
20730 fn preparing_pull_journal_discards_only_private_staging() {
20731 let sandbox = tempfile::TempDir::new().unwrap();
20732 let root = sandbox.path().join("brain");
20733 std::fs::create_dir_all(root.join(".dbmd")).unwrap();
20734 std::fs::write(
20735 root.join("DB.md"),
20736 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20737 )
20738 .unwrap();
20739 let store = Store::open_strict(&root).unwrap();
20740 let bundle = crate::ulid::mint();
20741 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20742 store
20743 .create_private_dir_all(Path::new(&backup_dir))
20744 .unwrap();
20745 let journal = V2PullJournal {
20746 v: 1,
20747 phase: V2PullPhase::Preparing,
20748 brain: TEST_BRAIN_ID.to_string(),
20749 previous: V2PullCoordinate {
20750 head_seq: None,
20751 commit_hash: None,
20752 view_kind: None,
20753 view_revision: None,
20754 },
20755 next: V2PullCoordinate {
20756 head_seq: Some(1),
20757 commit_hash: Some("a".repeat(64)),
20758 view_kind: Some("full".to_string()),
20759 view_revision: Some("b".repeat(64)),
20760 },
20761 backup_dir: backup_dir.clone(),
20762 entries: vec![V2PullJournalEntry {
20763 path: "records/new.md".to_string(),
20764 old: None,
20765 new: Some(V2PullFileCoordinate {
20766 sha256: "c".repeat(64),
20767 bytes: 1,
20768 }),
20769 backup: None,
20770 }],
20771 };
20772 store
20773 .write_private_atomic_new(
20774 Path::new(V2_PULL_JOURNAL),
20775 &v2_pull_journal_bytes(&journal).unwrap(),
20776 )
20777 .unwrap();
20778 let cfg = test_hub_config(
20779 "https://example.test".to_string(),
20780 sandbox.path().join("state"),
20781 );
20782
20783 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20784
20785 assert!(root.join("DB.md").is_file());
20786 assert!(!root.join(V2_PULL_JOURNAL).exists());
20787 assert!(!root.join(backup_dir).exists());
20788 }
20789
20790 #[test]
20791 fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
20792 let sandbox = tempfile::TempDir::new().unwrap();
20793 let root = sandbox.path().join("brain");
20794 std::fs::create_dir_all(root.join("records")).unwrap();
20795 std::fs::write(
20796 root.join("DB.md"),
20797 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20798 )
20799 .unwrap();
20800 let new = b"---\ntype: note\n---\n\nnew\n";
20801 std::fs::write(root.join("records/value.md"), new).unwrap();
20802 let store = Store::open_strict(&root).unwrap();
20803 let bundle = crate::ulid::mint();
20804 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20805 store
20806 .create_private_dir_all(Path::new(&backup_dir))
20807 .unwrap();
20808 let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
20809 store.create_private_dir_all(Path::new(&orphan)).unwrap();
20810 let next = V2PullCoordinate {
20811 head_seq: Some(2),
20812 commit_hash: Some("c".repeat(64)),
20813 view_kind: Some("full".to_string()),
20814 view_revision: Some("d".repeat(64)),
20815 };
20816 let journal = V2PullJournal {
20817 v: 1,
20818 phase: V2PullPhase::Ready,
20819 brain: TEST_BRAIN_ID.to_string(),
20820 previous: V2PullCoordinate {
20821 head_seq: Some(1),
20822 commit_hash: Some("a".repeat(64)),
20823 view_kind: Some("full".to_string()),
20824 view_revision: Some("b".repeat(64)),
20825 },
20826 next: next.clone(),
20827 backup_dir: backup_dir.clone(),
20828 entries: vec![V2PullJournalEntry {
20829 path: "records/value.md".to_string(),
20830 old: Some(V2PullFileCoordinate {
20831 sha256: "e".repeat(64),
20832 bytes: new.len() as u64,
20833 }),
20834 new: Some(V2PullFileCoordinate {
20835 sha256: content_sha256(new),
20836 bytes: new.len() as u64,
20837 }),
20838 backup: Some("00000000".to_string()),
20839 }],
20840 };
20841 store
20842 .write_private_atomic_new(
20843 Path::new(V2_PULL_JOURNAL),
20844 &v2_pull_journal_bytes(&journal).unwrap(),
20845 )
20846 .unwrap();
20847 let cfg = test_hub_config(
20848 "https://example.test".to_string(),
20849 sandbox.path().join("state"),
20850 );
20851 save_v2_baseline(
20852 &cfg,
20853 TEST_BRAIN_ID,
20854 &root,
20855 &V2SyncBaseline {
20856 v: 2,
20857 origin: "https://example.test".to_string(),
20858 brain: TEST_BRAIN_ID.to_string(),
20859 checkout_id: Some("c".repeat(64)),
20860 head_seq: next.head_seq,
20861 commit_hash: next.commit_hash.clone(),
20862 content_root: Some("f".repeat(64)),
20863 asset_root: None,
20864 assets: Default::default(),
20865 view_kind: next.view_kind.clone(),
20866 view_revision: next.view_revision.clone(),
20867 control_revision: Some("d".repeat(64)),
20868 projection_sha256: None,
20869 files: Default::default(),
20870 scan_cache: Default::default(),
20871 local_policy_digest: None,
20872 local_eligibility: Default::default(),
20873 remote_copy_remains: Default::default(),
20874 },
20875 )
20876 .unwrap();
20877
20878 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20879
20880 assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
20881 assert!(!root.join(V2_PULL_JOURNAL).exists());
20882 assert!(!root.join(backup_dir).exists());
20883 assert!(!root.join(orphan).exists());
20884 }
20885
20886 #[test]
20887 fn only_typed_validation_projection_lag_retries_v2_head() {
20888 let typed = HubResponse {
20889 status: 422,
20890 body: Some(json!({ "code": "validation_index_catching_up" })),
20891 };
20892 let nested = HubResponse {
20893 status: 422,
20894 body: Some(json!({
20895 "details": { "code": "validation_index_catching_up" }
20896 })),
20897 };
20898 let unrelated = HubResponse {
20899 status: 422,
20900 body: Some(json!({ "code": "source_immutable" })),
20901 };
20902 let wrong_status = HubResponse {
20903 status: 403,
20904 body: typed.body.clone(),
20905 };
20906 assert!(v2_validation_catching_up(&typed));
20907 assert!(v2_validation_catching_up(&nested));
20908 assert!(!v2_validation_catching_up(&unrelated));
20909 assert!(!v2_validation_catching_up(&wrong_status));
20910 }
20911
20912 #[test]
20913 fn asset_resolution_is_limited_to_explicitly_resolved_wrappers() {
20914 let wrapper = "records/operational/package.md".to_string();
20915 let mut resolution = std::collections::BTreeMap::new();
20916 resolution.insert(
20917 wrapper.clone(),
20918 V2ResolutionOverride {
20919 expected_remote: Some("a".repeat(64)),
20920 selected_local: Some("b".repeat(64)),
20921 },
20922 );
20923 let local = crate::AssetRecord {
20924 path: "sources/package/object.blob".to_string(),
20925 sha256: "c".repeat(64),
20926 bytes: 1,
20927 media_type: "application/octet-stream".to_string(),
20928 wrappers: vec![wrapper.clone()],
20929 required: true,
20930 };
20931 assert!(v2_resolution_allows_asset(
20932 Some(&resolution),
20933 None,
20934 None,
20935 Some(&local),
20936 ));
20937 let unrelated = crate::AssetRecord {
20938 wrappers: vec!["records/unrelated.md".to_string()],
20939 ..local
20940 };
20941 assert!(!v2_resolution_allows_asset(
20942 Some(&resolution),
20943 None,
20944 None,
20945 Some(&unrelated),
20946 ));
20947 assert!(!v2_resolution_allows_asset(
20948 None,
20949 None,
20950 None,
20951 Some(&unrelated),
20952 ));
20953 }
20954}