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_PULL_TRANSACTION_BYTES: u64 = MAX_ASSET_BYTES;
156const MAX_PACK_BYTES: u64 =
159 MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
160const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
169const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
170
171const MAX_IDENTITY_ROTATIONS: usize = 1_024;
174
175fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
178 let mut batches: Vec<Vec<Value>> = Vec::new();
179 let mut current: Vec<Value> = Vec::new();
180 let mut current_bytes = 0usize;
181 for declaration in declarations {
182 let declared_bytes = serde_json::to_string(&declaration)
183 .map(|text| text.len())
184 .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
185 + 1;
186 if !current.is_empty()
187 && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
188 || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
189 {
190 batches.push(std::mem::take(&mut current));
191 current_bytes = 0;
192 }
193 current_bytes += declared_bytes;
194 current.push(declaration);
195 }
196 if !current.is_empty() {
197 batches.push(current);
198 }
199 batches
200}
201const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
205const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
206const FEED_PAGE_LIMIT: usize = 100;
207
208pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
213
214const CONNECT_TIMEOUT_SECS: u64 = 10;
217const READ_TIMEOUT_SECS: u64 = 120;
218const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
222const COMMIT_REQUEST_TIMEOUT_SECS: u64 = 900;
229const COMMIT_ATTEMPTS: usize = 4;
233const COMMIT_RETRY_BACKOFF_MS: [u64; COMMIT_ATTEMPTS - 1] = [5_000, 20_000, 45_000];
234const CONNECT_ATTEMPTS: usize = 3;
235const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
236const SAFE_READ_ATTEMPTS: usize = 4;
241const SAFE_READ_RETRY_BACKOFF_MS: [u64; SAFE_READ_ATTEMPTS - 1] = [200, 1_000, 3_000];
242
243const UPLOAD_ATTEMPTS: usize = 6;
247const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
248const UPLOAD_TOTAL_TIMEOUT_SECS: u64 = 300;
252
253fn upload_retry_backoff_ms(attempt: usize) -> u64 {
254 UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
255}
256
257fn upload_deadline_error() -> LinkError {
258 LinkError::Transport {
259 hub: "the object store".to_string(),
260 message: "network error (upload deadline exceeded)".to_string(),
261 }
262}
263
264fn upload_attempt_timeout(deadline: std::time::Instant) -> LinkResult<std::time::Duration> {
265 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
266 if remaining.is_zero() {
267 return Err(upload_deadline_error());
268 }
269 Ok(remaining.min(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS)))
270}
271
272fn wait_for_upload_retry(deadline: std::time::Instant, attempt: usize) -> bool {
273 if attempt + 1 >= UPLOAD_ATTEMPTS {
274 return false;
275 }
276 let pause = std::time::Duration::from_millis(upload_retry_backoff_ms(attempt));
277 if deadline.saturating_duration_since(std::time::Instant::now()) <= pause {
278 return false;
279 }
280 std::thread::sleep(pause);
281 true
282}
283
284const RESERVATION_ATTEMPTS: usize = 7;
289const RESERVATION_BACKOFF_MS: [u64; RESERVATION_ATTEMPTS - 1] =
290 [500, 2_000, 5_000, 15_000, 30_000, 60_000];
291
292fn is_retryable_hub_status(status: u16) -> bool {
296 matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
297}
298
299fn is_retryable_upload_status(status: u16) -> bool {
303 matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
304}
305const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
309#[cfg(unix)]
313const V2_PULL_INSTALL_WORKERS: usize = 16;
314const V2_BULK_STREAM_FILES: usize = 256;
318const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
319const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
320const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
321const V2_DOWNLOAD_CAPABILITY_FILES: usize = V2_BLOB_DOWNLOAD_WORKERS;
326const V2_DOWNLOAD_CAPABILITY_BYTES: u64 = 512 * 1024 * 1024;
327const V2_DOWNLOAD_CAPABILITY_ATTEMPTS: usize = 4;
328const V2_DOWNLOAD_CAPABILITY_BACKOFF_MS: [u64; V2_DOWNLOAD_CAPABILITY_ATTEMPTS - 1] =
329 [200, 1_000, 3_000];
330
331#[derive(Debug, thiserror::Error)]
335pub enum LinkError {
336 #[error(
338 "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
339 )]
340 NoHub,
341
342 #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
344 NoCredential,
345
346 #[error(
349 "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
350 )]
351 BadKey,
352
353 #[error(
359 "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}"
360 )]
361 UnboundCredential,
362
363 #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
367 BadAgentKey {
368 message: String,
370 },
371
372 #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
374 UnsafeHub {
375 hub: String,
377 },
378
379 #[error("hub unreachable at {hub}: {message}")]
381 Transport {
382 hub: String,
384 message: String,
386 },
387
388 #[error("{what} failed (HTTP {status}): {message}")]
390 Http {
391 what: &'static str,
393 status: u16,
395 message: String,
397 code: Option<String>,
399 details: Option<Value>,
401 },
402
403 #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
406 NotJson {
407 what: &'static str,
409 status: u16,
411 },
412
413 #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
415 ResponseTooLarge {
416 limit_bytes: u64,
418 },
419
420 #[error("invalid address `{given}`: {reason}")]
422 BadAddress {
423 given: String,
425 reason: String,
427 },
428
429 #[error(
431 "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
432 )]
433 BadGrantId {
434 given: String,
436 },
437
438 #[error("refusing unsafe path from the hub: `{path}`")]
442 UnsafePath {
443 path: String,
445 },
446
447 #[error(
449 "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
450 MAX_STORE_BYTES / (1024 * 1024),
451 MAX_PACK_BYTES / (1024 * 1024)
452 )]
453 PushTooLarge {
454 detail: String,
456 },
457
458 #[error(
460 "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
461 MAX_PROPOSE_BYTES / 1024
462 )]
463 ProposeTooLarge {
464 bytes: u64,
466 },
467
468 #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
470 NotUtf8 {
471 path: String,
473 },
474
475 #[error("invalid store pack: {message}")]
477 InvalidPack {
478 message: String,
480 },
481
482 #[error("invalid signed feed: {message}")]
484 InvalidFeed {
485 message: String,
487 },
488
489 #[error(
493 "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}`"
494 )]
495 AliasRebindRequired {
496 alias: String,
497 from: String,
498 to: String,
499 },
500
501 #[error("sync conflict on {paths:?} — resolve the named files and retry")]
504 Conflict {
505 paths: Vec<String>,
507 },
508
509 #[error(
513 "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
514 )]
515 ConflictBundle {
516 bundle: String,
518 paths: Vec<String>,
520 },
521
522 #[error(
526 "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
527 )]
528 LocalPolicyTransition {
529 paths: Vec<String>,
531 },
532
533 #[error(
537 "hosted assets require explicit withdrawal {paths:?} — retry with one --withdraw-from-hosting <path> per asset and a non-empty --withdraw-reason"
538 )]
539 AssetWithdrawalRequired {
540 paths: Vec<String>,
542 },
543
544 #[error(
549 "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
550 )]
551 BulkPreviewRequired {
552 preview: Value,
554 },
555
556 #[error(
559 "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
560 )]
561 ScopedProjectionModified,
562
563 #[error(
567 "the checkout's permission scope changed — clone into a new directory to accept the new view"
568 )]
569 ScopedViewChanged,
570
571 #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
574 BrainUnavailable,
575
576 #[error(
579 "the remote brain advanced during sync — retry to converge from the new verified head"
580 )]
581 RemoteAdvancedDuringSync,
582
583 #[error(
586 "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
587 )]
588 UnsupportedPlatform {
589 operation: &'static str,
591 },
592
593 #[error(transparent)]
595 Io(#[from] std::io::Error),
596
597 #[error(transparent)]
599 Store(#[from] crate::StoreError),
600}
601
602pub type LinkResult<T> = std::result::Result<T, LinkError>;
604
605#[derive(Debug, Clone, PartialEq, Eq)]
607pub struct V2BulkConfirmation {
608 pub id: String,
610 pub digest: String,
613}
614
615impl V2BulkConfirmation {
616 pub fn parse(value: &str) -> LinkResult<Self> {
619 let (id, digest) = value
620 .split_once(':')
621 .ok_or_else(|| LinkError::InvalidPack {
622 message: "bulk confirmation must be <id>:<digest>".to_string(),
623 })?;
624 if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
625 return Err(LinkError::InvalidPack {
626 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
627 .to_string(),
628 });
629 }
630 Ok(Self {
631 id: id.to_string(),
632 digest: digest.to_string(),
633 })
634 }
635}
636
637fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
642 #[cfg(any(target_os = "linux", target_os = "macos", windows))]
643 {
644 let _ = operation;
645 Ok(())
646 }
647 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
648 {
649 Err(LinkError::UnsupportedPlatform { operation })
650 }
651}
652
653#[derive(Debug, Clone, PartialEq, Eq)]
659pub enum AddressTarget {
660 Id(String),
662 Path(String),
666}
667
668const BAD_BRAIN_REASON: &str =
671 "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
672
673const BAD_TARGET_REASON: &str =
676 "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
677
678#[derive(Debug, Clone, PartialEq, Eq)]
683pub struct Address {
684 pub brain: String,
686 pub target: Option<AddressTarget>,
688}
689
690impl Address {
691 pub fn parse(raw: &str) -> LinkResult<Address> {
695 let bad = |reason: &str| LinkError::BadAddress {
696 given: raw.to_string(),
697 reason: reason.to_string(),
698 };
699
700 let trimmed = raw.trim();
701 let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
702 if body.is_empty() {
703 return Err(bad("empty address"));
704 }
705
706 let (brain, rest) = match body.split_once('/') {
707 Some((b, r)) => (b, Some(r)),
708 None => (body, None),
709 };
710
711 if brain.is_empty() {
712 return Err(bad("missing brain reference before `/`"));
713 }
714 if !is_safe_ref(brain) {
715 return Err(bad(BAD_BRAIN_REASON));
716 }
717
718 let target = match rest {
719 None => None,
720 Some("") => return Err(bad("trailing `/` with no record id or path")),
721 Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
722 Some(r) => {
723 if !safe_store_rel_path(r) || !r.ends_with(".md") {
724 return Err(bad(BAD_TARGET_REASON));
725 }
726 Some(AddressTarget::Path(r.to_string()))
727 }
728 };
729
730 Ok(Address {
731 brain: brain.to_string(),
732 target,
733 })
734 }
735}
736
737fn is_safe_ref(s: &str) -> bool {
740 !s.is_empty()
741 && s.len() <= 64
742 && s.bytes()
743 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
744}
745
746pub fn is_valid_handle(s: &str) -> bool {
749 is_safe_ref(s)
750}
751
752pub fn safe_store_rel_path(p: &str) -> bool {
758 if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
759 return false;
760 }
761 if !p
762 .bytes()
763 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
764 {
765 return false;
766 }
767 p.split('/')
768 .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
769}
770
771fn require_safe_ref(brain: &str) -> LinkResult<()> {
779 if is_safe_ref(brain) {
780 Ok(())
781 } else {
782 Err(LinkError::BadAddress {
783 given: brain.to_string(),
784 reason: BAD_BRAIN_REASON.to_string(),
785 })
786 }
787}
788
789fn require_valid_handle(handle: &str) -> LinkResult<()> {
791 if is_valid_handle(handle) {
792 Ok(())
793 } else {
794 Err(LinkError::BadAddress {
795 given: handle.to_string(),
796 reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
797 })
798 }
799}
800
801fn require_safe_grant_id(id: &str) -> LinkResult<()> {
805 if is_safe_ref(id) {
806 Ok(())
807 } else {
808 Err(LinkError::BadGrantId {
809 given: id.to_string(),
810 })
811 }
812}
813
814#[derive(Debug, Clone)]
820pub struct HubConfig {
821 pub hub: String,
823 pub key: Option<String>,
825 pub agent_key: Option<AgentSigningKey>,
828 pub brain_key: Option<AgentSigningKey>,
831 pub state_dir: PathBuf,
834 store_selected: bool,
837}
838
839#[derive(Clone)]
842pub struct AgentSigningKey {
843 pkcs8: Vec<u8>,
844 pub multikey: String,
846 pub public_key_spki: String,
848}
849
850impl std::fmt::Debug for AgentSigningKey {
851 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
852 f.debug_struct("AgentSigningKey")
853 .field("multikey", &self.multikey)
854 .field("pkcs8", &"<redacted>")
855 .finish()
856 }
857}
858
859impl HubConfig {
860 pub fn require_key(&self) -> LinkResult<&str> {
863 self.key.as_deref().ok_or(LinkError::NoCredential)
864 }
865}
866
867pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
872 let explicit_hub = flag_hub
873 .map(str::to_string)
874 .or_else(|| env_nonempty(HUB_URL_ENV));
875 let selected_by_store = explicit_hub.is_none();
876 let hub = explicit_hub
877 .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
878 .ok_or(LinkError::NoHub)?;
879 let hub = hub.trim().trim_end_matches('/').to_string();
880 assert_safe_hub(&hub)?;
881 if selected_by_store {
882 let parsed =
883 url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
884 if !parsed.scheme().eq_ignore_ascii_case("https")
888 || (parsed.path() != "/" && !parsed.path().is_empty())
889 {
890 return Err(LinkError::UnsafeHub { hub });
891 }
892 }
893
894 let key = match env_nonempty(HUB_KEY_ENV) {
895 Some(raw) => Some(clean_key(&raw)?),
896 None => None,
897 };
898
899 let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
900 Some(path) => Some(load_agent_key(Path::new(&path))?),
901 None => None,
902 };
903
904 let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
905 Some(path) => Some(load_agent_key(Path::new(&path))?),
906 None => None,
907 };
908
909 if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
916 let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
917 .and_then(|value| normalized_origin(&value).ok());
918 let selected_origin = normalized_origin(&hub)?;
919 if bound.as_deref() != Some(selected_origin.as_str()) {
920 return Err(LinkError::UnboundCredential);
921 }
922 }
923
924 Ok(HubConfig {
925 hub,
926 key,
927 agent_key,
928 brain_key,
929 state_dir: toolkit_state_dir()?,
930 store_selected: selected_by_store,
931 })
932}
933
934fn toolkit_state_dir() -> LinkResult<PathBuf> {
935 if let Some(path) = env_nonempty(STATE_DIR_ENV) {
936 let path = PathBuf::from(path);
937 if !path.is_absolute() {
938 return Err(LinkError::UnsafePath {
939 path: path.display().to_string(),
940 });
941 }
942 return Ok(path);
943 }
944 #[cfg(windows)]
945 if let Some(base) = env_nonempty("LOCALAPPDATA") {
946 let base = PathBuf::from(base);
947 if base.is_absolute() {
948 return Ok(base.join("dbmd").join("state"));
949 }
950 }
951 #[cfg(windows)]
952 {
953 Err(LinkError::Io(std::io::Error::new(
954 std::io::ErrorKind::NotFound,
955 format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
956 )))
957 }
958 #[cfg(not(windows))]
959 if let Some(base) = env_nonempty("XDG_STATE_HOME") {
960 let base = PathBuf::from(base);
961 if base.is_absolute() {
962 return Ok(base.join("dbmd"));
963 }
964 }
965 #[cfg(not(windows))]
966 let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
967 LinkError::Io(std::io::Error::new(
968 std::io::ErrorKind::NotFound,
969 format!("cannot locate user state; set {STATE_DIR_ENV}"),
970 ))
971 })?);
972 #[cfg(not(windows))]
973 if !home.is_absolute() {
974 return Err(LinkError::UnsafePath {
975 path: home.display().to_string(),
976 });
977 }
978 #[cfg(target_os = "macos")]
979 {
980 Ok(home
981 .join("Library")
982 .join("Application Support")
983 .join("dbmd")
984 .join("state"))
985 }
986 #[cfg(all(not(target_os = "macos"), not(windows)))]
987 {
988 Ok(home.join(".local").join("state").join("dbmd"))
989 }
990}
991
992fn normalized_origin(value: &str) -> LinkResult<String> {
993 let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
994 hub: value.to_string(),
995 })?;
996 if !(parsed.scheme().eq_ignore_ascii_case("https")
997 || parsed.scheme().eq_ignore_ascii_case("http"))
998 || !parsed.username().is_empty()
999 || parsed.password().is_some()
1000 || (parsed.path() != "/" && !parsed.path().is_empty())
1001 || parsed.query().is_some()
1002 || parsed.fragment().is_some()
1003 {
1004 return Err(LinkError::UnsafeHub {
1005 hub: value.to_string(),
1006 });
1007 }
1008 let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
1009 hub: value.to_string(),
1010 })?;
1011 let host = if host.contains(':') {
1012 format!("[{host}]")
1013 } else {
1014 host.to_ascii_lowercase()
1015 };
1016 let port = parsed
1017 .port_or_known_default()
1018 .ok_or_else(|| LinkError::UnsafeHub {
1019 hub: value.to_string(),
1020 })?;
1021 let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
1022 || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
1023 Ok(format!(
1024 "{}://{}{}",
1025 parsed.scheme().to_ascii_lowercase(),
1026 host,
1027 if default {
1028 String::new()
1029 } else {
1030 format!(":{port}")
1031 }
1032 ))
1033}
1034
1035const ED25519_SPKI_PREFIX: [u8; 12] = [
1042 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1043];
1044
1045fn bad_agent_key(message: &str) -> LinkError {
1046 LinkError::BadAgentKey {
1047 message: message.to_string(),
1048 }
1049}
1050
1051fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
1052 ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
1056 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
1057 .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
1058}
1059
1060fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
1062 use ring::signature::KeyPair as _;
1063 let mut spki = Vec::with_capacity(44);
1064 spki.extend_from_slice(&ED25519_SPKI_PREFIX);
1065 spki.extend_from_slice(pair.public_key().as_ref());
1066 (
1067 URL_SAFE_NO_PAD.encode(&spki),
1068 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
1069 )
1070}
1071
1072pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1076 load_agent_key(path)
1077}
1078
1079fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1081 #[cfg(unix)]
1082 let file = {
1083 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1084 use std::os::unix::ffi::OsStrExt as _;
1085 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1086 .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1087 let leaf = path
1088 .file_name()
1089 .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1090 let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1091 let fd = unsafe {
1092 libc::openat(
1093 parent.as_raw_fd(),
1094 leaf.as_ptr(),
1095 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1096 )
1097 };
1098 if fd < 0 {
1099 return Err(bad_agent_key(
1100 "the key path must be an existing regular file without symlink ancestors",
1101 ));
1102 }
1103 unsafe { std::fs::File::from_raw_fd(fd) }
1104 };
1105 #[cfg(not(unix))]
1106 let file = std::fs::File::open(path)
1107 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1108 let metadata = file
1109 .metadata()
1110 .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1111 if !metadata.is_file() {
1112 return Err(bad_agent_key("the key path must be a regular file"));
1113 }
1114 #[cfg(unix)]
1115 {
1116 use std::os::unix::fs::PermissionsExt as _;
1117 if metadata.permissions().mode() & 0o077 != 0 {
1118 return Err(bad_agent_key(
1119 "the key file is accessible to group/other; set mode 0600",
1120 ));
1121 }
1122 }
1123 let mut text = String::new();
1124 file.take(1024 * 1024 + 1)
1125 .read_to_string(&mut text)
1126 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1127 if text.len() > 1024 * 1024 {
1128 return Err(bad_agent_key("the key file exceeds the size limit"));
1129 }
1130 let pkcs8 = URL_SAFE_NO_PAD
1131 .decode(text.trim())
1132 .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1133 let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1134 Ok(AgentSigningKey {
1135 pkcs8,
1136 multikey,
1137 public_key_spki,
1138 })
1139}
1140
1141fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1147 #[cfg(unix)]
1148 let (mut file, parent, leaf) = {
1149 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1150 use std::os::unix::ffi::OsStrExt as _;
1151 let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1152 let leaf_name = path
1153 .file_name()
1154 .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1155 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1156 let fd = unsafe {
1157 libc::openat(
1158 parent.as_raw_fd(),
1159 leaf.as_ptr(),
1160 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1161 0o600,
1162 )
1163 };
1164 if fd < 0 {
1165 let error = std::io::Error::last_os_error();
1166 if error.kind() == std::io::ErrorKind::AlreadyExists {
1167 return Err(bad_agent_key(
1168 "the output file already exists — refusing to overwrite a key",
1169 ));
1170 }
1171 return Err(error.into());
1172 }
1173 (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1174 };
1175 #[cfg(not(unix))]
1176 let mut file = std::fs::OpenOptions::new()
1177 .write(true)
1178 .create_new(true)
1179 .open(path)
1180 .map_err(|error| {
1181 if error.kind() == std::io::ErrorKind::AlreadyExists {
1182 bad_agent_key("the output file already exists — refusing to overwrite a key")
1183 } else {
1184 LinkError::Io(error)
1185 }
1186 })?;
1187 if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1188 drop(file);
1189 #[cfg(unix)]
1190 let _ =
1191 unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1192 #[cfg(not(unix))]
1193 let _ = std::fs::remove_file(path);
1194 return Err(LinkError::Io(error));
1195 }
1196 drop(file);
1197 #[cfg(unix)]
1198 parent.sync_all()?;
1199 Ok(())
1200}
1201
1202#[derive(Debug, Serialize)]
1205pub struct GeneratedAgentKey {
1206 pub multikey: String,
1208 #[serde(rename = "publicKeySpki")]
1210 pub public_key_spki: String,
1211 #[serde(rename = "keyFile")]
1213 pub key_file: String,
1214}
1215
1216pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1221 require_hardened_filesystem("key generation")?;
1222 let rng = ring::rand::SystemRandom::new();
1223 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1224 .map_err(|_| bad_agent_key("key generation failed"))?;
1225 let pair = agent_keypair(pkcs8.as_ref())?;
1226 let (spki_b64u, multikey) = public_identity_for(&pair);
1227
1228 write_secret_new(
1229 out,
1230 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1231 )?;
1232
1233 Ok(GeneratedAgentKey {
1234 multikey,
1235 public_key_spki: spki_b64u,
1236 key_file: out.display().to_string(),
1237 })
1238}
1239
1240fn linkmd_sig_header(
1249 key: &AgentSigningKey,
1250 origin: &str,
1251 method: &str,
1252 path: &str,
1253 body: Option<&str>,
1254) -> LinkResult<String> {
1255 let ts = std::time::SystemTime::now()
1256 .duration_since(std::time::UNIX_EPOCH)
1257 .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1258 .as_secs();
1259 let body_hash = match body {
1260 Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1261 None => "-".to_string(),
1262 };
1263 let canonical = format!(
1264 "v2\n{}\n{}\n{}\n{}\n{}",
1265 origin,
1266 method.to_uppercase(),
1267 path,
1268 ts,
1269 body_hash
1270 );
1271 let pair = agent_keypair(&key.pkcs8)?;
1272 let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1273 let fingerprint = key.multikey.trim_start_matches("ed25519:");
1274 Ok(format!(
1275 "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1276 ))
1277}
1278
1279#[derive(Serialize)]
1286struct WireFeedFile {
1287 path: String,
1288 sha256: String,
1289 bytes: u64,
1290}
1291
1292#[derive(Serialize)]
1295struct UnsignedWireEntry<'a> {
1296 v: u8,
1297 seq: u64,
1298 ts: String,
1299 brain: &'a str,
1300 public_key: &'a str,
1301 kind: &'a str,
1302 op: &'a str,
1303 pack_sha256: &'a str,
1304 files: &'a [WireFeedFile],
1305 removed: &'a [String],
1306 prev_entry_hash: Option<&'a str>,
1307}
1308
1309fn self_custody_entry(
1315 key: &AgentSigningKey,
1316 seq: u64,
1317 ts: String,
1318 pack_sha256: &str,
1319 files: &[WireFeedFile],
1320 prev_entry_hash: Option<&str>,
1321) -> LinkResult<String> {
1322 let removed: [String; 0] = [];
1323 let unsigned = serde_json::to_string(&UnsignedWireEntry {
1324 v: 1,
1325 seq,
1326 ts,
1327 brain: &key.multikey,
1328 public_key: &key.public_key_spki,
1329 kind: "push",
1330 op: "snapshot",
1331 pack_sha256,
1332 files,
1333 removed: &removed,
1334 prev_entry_hash,
1335 })
1336 .expect("serialize feed entry");
1337 let pair = agent_keypair(&key.pkcs8)?;
1338 let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1339 Ok(format!(
1340 "{},\"sig\":\"{}\"}}",
1341 &unsigned[..unsigned.len() - 1],
1342 sig
1343 ))
1344}
1345
1346fn env_nonempty(name: &str) -> Option<String> {
1349 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1350}
1351
1352fn config_file_hub(path: &Path) -> Option<String> {
1357 const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1358 #[cfg(unix)]
1359 let file = {
1360 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1361 use std::os::unix::ffi::OsStrExt as _;
1362 let parent =
1363 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1364 let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1365 let fd = unsafe {
1366 libc::openat(
1367 parent.as_raw_fd(),
1368 leaf.as_ptr(),
1369 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1370 )
1371 };
1372 if fd < 0 {
1373 return None;
1374 }
1375 unsafe { std::fs::File::from_raw_fd(fd) }
1376 };
1377 #[cfg(not(unix))]
1378 let file = std::fs::File::open(path).ok()?;
1379 let metadata = file.metadata().ok()?;
1380 if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1381 return None;
1382 }
1383 let mut bytes = Vec::with_capacity(metadata.len() as usize);
1384 file.take(MAX_CONFIG_BYTES + 1)
1385 .read_to_end(&mut bytes)
1386 .ok()?;
1387 if bytes.len() as u64 > MAX_CONFIG_BYTES {
1388 return None;
1389 }
1390 let text = String::from_utf8(bytes).ok()?;
1391 for line in text.lines() {
1392 let line = line.trim();
1393 if line.is_empty() || line.starts_with('#') {
1394 continue;
1395 }
1396 if let Some((k, v)) = line.split_once('=') {
1397 if k.trim() == "hub" {
1398 let v = v.trim();
1399 if !v.is_empty() {
1400 return Some(v.to_string());
1401 }
1402 }
1403 }
1404 }
1405 None
1406}
1407
1408fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1411 let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1412 hub: hub.to_string(),
1413 })?;
1414 if !(parsed.scheme().eq_ignore_ascii_case("https")
1415 || parsed.scheme().eq_ignore_ascii_case("http"))
1416 || !parsed.username().is_empty()
1417 || parsed.password().is_some()
1418 || (parsed.path() != "/" && !parsed.path().is_empty())
1419 || parsed.query().is_some()
1420 || parsed.fragment().is_some()
1421 {
1422 return Err(LinkError::UnsafeHub {
1423 hub: hub.to_string(),
1424 });
1425 }
1426 let loopback = match parsed.host() {
1427 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1428 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1429 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1430 None => false,
1431 };
1432 if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1433 Ok(())
1434 } else {
1435 Err(LinkError::UnsafeHub {
1436 hub: hub.to_string(),
1437 })
1438 }
1439}
1440
1441fn clean_key(raw: &str) -> LinkResult<String> {
1446 let k = raw.trim();
1447 if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1448 return Err(LinkError::BadKey);
1449 }
1450 Ok(k.to_string())
1451}
1452
1453#[derive(Debug)]
1459pub struct HubResponse {
1460 pub status: u16,
1462 pub body: Option<Value>,
1464}
1465
1466struct RawHubResponse {
1467 status: u16,
1468 body: Vec<u8>,
1469}
1470
1471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1473enum Auth {
1474 Required,
1476 None,
1478 Optional,
1482}
1483
1484fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1485 ureq::AgentBuilder::new()
1486 .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1487 .redirects(0)
1491 .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1492 .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1493 .timeout_write(overall)
1494 .timeout(overall)
1495}
1496
1497fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1498 hub_agent_with_timeout(
1499 cfg,
1500 std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1501 )
1502}
1503
1504fn hub_agent_with_timeout(
1505 cfg: &HubConfig,
1506 overall: std::time::Duration,
1507) -> LinkResult<ureq::Agent> {
1508 if !cfg.store_selected {
1509 return Ok(agent_builder_with_timeout(overall).build());
1510 }
1511 let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1512 hub: cfg.hub.clone(),
1513 })?;
1514 pinned_public_agent_pooled(
1515 &parsed,
1516 false,
1517 "store-selected hub",
1518 AgentShape {
1519 overall,
1520 ..AgentShape::default()
1521 },
1522 )
1523}
1524
1525fn request_raw(
1530 cfg: &HubConfig,
1531 method: &str,
1532 path: &str,
1533 body: Option<&Value>,
1534 auth: Auth,
1535 max_response_bytes: u64,
1536) -> LinkResult<RawHubResponse> {
1537 let http = hub_agent(cfg)?;
1538 request_raw_with_agent(
1539 cfg,
1540 &http,
1541 method,
1542 path,
1543 body,
1544 RawRequestOptions {
1545 auth,
1546 max_response_bytes,
1547 request_id: None,
1548 retry_transport: false,
1549 },
1550 )
1551}
1552
1553fn request_raw_retryable_read(
1557 cfg: &HubConfig,
1558 method: &str,
1559 path: &str,
1560 body: Option<&Value>,
1561 auth: Auth,
1562 max_response_bytes: u64,
1563) -> LinkResult<RawHubResponse> {
1564 let http = hub_agent(cfg)?;
1565 request_raw_with_agent(
1566 cfg,
1567 &http,
1568 method,
1569 path,
1570 body,
1571 RawRequestOptions {
1572 auth,
1573 max_response_bytes,
1574 request_id: None,
1575 retry_transport: true,
1576 },
1577 )
1578}
1579
1580struct RawRequestOptions<'a> {
1581 auth: Auth,
1582 max_response_bytes: u64,
1583 request_id: Option<&'a str>,
1584 retry_transport: bool,
1585}
1586
1587fn request_raw_with_agent(
1588 cfg: &HubConfig,
1589 http: &ureq::Agent,
1590 method: &str,
1591 path: &str,
1592 body: Option<&Value>,
1593 options: RawRequestOptions<'_>,
1594) -> LinkResult<RawHubResponse> {
1595 let url = format!("{}{}", cfg.hub, path);
1596 let encoded_body = body.map(Value::to_string);
1597 let origin = normalized_origin(&cfg.hub)?;
1598 let safe_read = (method == "GET" && encoded_body.is_none()) || options.retry_transport;
1599 let mut read_attempt = 0;
1600 loop {
1601 let credential = match options.auth {
1608 Auth::Required => Some(match &cfg.agent_key {
1609 Some(key) => {
1610 linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?
1611 }
1612 None => format!("Bearer {}", cfg.require_key()?),
1613 }),
1614 Auth::Optional => match &cfg.agent_key {
1615 Some(key) => Some(linkmd_sig_header(
1616 key,
1617 &origin,
1618 method,
1619 path,
1620 encoded_body.as_deref(),
1621 )?),
1622 None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1623 },
1624 Auth::None => None,
1625 };
1626 let result = with_connect_retries(|| {
1627 let mut req = http.request(method, &url);
1628 if let Some(value) = &credential {
1629 req = req.set("authorization", value);
1630 }
1631 if let Some(value) = options.request_id {
1632 req = req.set("x-request-id", value);
1633 }
1634 match &encoded_body {
1635 Some(value) => req
1636 .set("content-type", "application/json")
1637 .send_string(value)
1638 .map_err(Box::new),
1639 None => req.call().map_err(Box::new),
1640 }
1641 });
1642 let resp = match result {
1643 Ok(resp) => resp,
1644 Err(error) => match *error {
1645 ureq::Error::Status(_, resp) => resp,
1646 ureq::Error::Transport(error) => {
1647 if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS {
1648 std::thread::sleep(std::time::Duration::from_millis(
1649 SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1650 ));
1651 read_attempt += 1;
1652 continue;
1653 }
1654 return Err(LinkError::Transport {
1655 hub: cfg.hub.clone(),
1656 message: error.to_string(),
1657 });
1658 }
1659 },
1660 };
1661
1662 let status = resp.status();
1663 let buf = match read_response_body(resp, options.max_response_bytes + 1, &cfg.hub) {
1664 Ok(buf) => buf,
1665 Err(LinkError::Transport { .. })
1666 if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS =>
1667 {
1668 std::thread::sleep(std::time::Duration::from_millis(
1669 SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1670 ));
1671 read_attempt += 1;
1672 continue;
1673 }
1674 Err(error) => return Err(error),
1675 };
1676 if buf.len() as u64 > options.max_response_bytes {
1677 return Err(LinkError::ResponseTooLarge {
1678 limit_bytes: options.max_response_bytes,
1679 });
1680 }
1681 return Ok(RawHubResponse { status, body: buf });
1682 }
1683}
1684
1685fn request_capped(
1686 cfg: &HubConfig,
1687 method: &str,
1688 path: &str,
1689 body: Option<&Value>,
1690 auth: Auth,
1691 max_response_bytes: u64,
1692) -> LinkResult<HubResponse> {
1693 let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1694 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1695 Ok(HubResponse {
1696 status: raw.status,
1697 body: parsed,
1698 })
1699}
1700
1701fn request_patient(
1713 cfg: &HubConfig,
1714 method: &str,
1715 path: &str,
1716 body: Option<&Value>,
1717 auth: Auth,
1718) -> LinkResult<HubResponse> {
1719 let http = hub_agent_with_timeout(
1720 cfg,
1721 std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1722 )?;
1723 let mut attempt = 0;
1724 let validation_started = std::time::Instant::now();
1725 let mut validation_wait = std::time::Duration::from_millis(250);
1726 loop {
1727 let sent = request_raw_with_agent(
1728 cfg,
1729 &http,
1730 method,
1731 path,
1732 body,
1733 RawRequestOptions {
1734 auth,
1735 max_response_bytes: MAX_RESPONSE_BYTES,
1736 request_id: None,
1737 retry_transport: false,
1738 },
1739 );
1740 match sent {
1741 Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1742 std::thread::sleep(std::time::Duration::from_millis(
1743 COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1744 ));
1745 attempt += 1;
1746 }
1747 Err(error) => return Err(error),
1748 Ok(raw) => {
1749 let response = HubResponse {
1750 status: raw.status,
1751 body: serde_json::from_slice(&raw.body).ok(),
1752 };
1753 if v2_validation_catching_up(&response) {
1760 if validation_started.elapsed() >= std::time::Duration::from_secs(15 * 60) {
1761 return Err(LinkError::Http {
1762 what: "v2 commit receipt",
1763 status: response.status,
1764 message: "validation/index recovery did not make the exact mutation receipt available within 15 minutes".to_string(),
1765 code: Some("validation_index_catching_up".to_string()),
1766 details: response.body,
1767 });
1768 }
1769 std::thread::sleep(validation_wait);
1770 validation_wait = validation_wait
1771 .saturating_mul(2)
1772 .min(std::time::Duration::from_secs(5));
1773 continue;
1774 }
1775 return Ok(response);
1776 }
1777 }
1778 }
1779}
1780
1781fn request(
1782 cfg: &HubConfig,
1783 method: &str,
1784 path: &str,
1785 body: Option<&Value>,
1786 auth: Auth,
1787) -> LinkResult<HubResponse> {
1788 request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1789}
1790
1791fn request_with_request_id(
1796 cfg: &HubConfig,
1797 method: &str,
1798 path: &str,
1799 body: Option<&Value>,
1800 auth: Auth,
1801 request_id: &str,
1802) -> LinkResult<HubResponse> {
1803 if request_id.is_empty()
1804 || request_id.len() > 128
1805 || !request_id
1806 .bytes()
1807 .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1808 {
1809 return Err(invalid_feed("hub returned an unsafe request id"));
1810 }
1811 let http = hub_agent_with_timeout(
1814 cfg,
1815 std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1816 )?;
1817 let raw = request_raw_with_agent(
1818 cfg,
1819 &http,
1820 method,
1821 path,
1822 body,
1823 RawRequestOptions {
1824 auth,
1825 max_response_bytes: MAX_RESPONSE_BYTES,
1826 request_id: Some(request_id),
1827 retry_transport: false,
1828 },
1829 )?;
1830 Ok(HubResponse {
1831 status: raw.status,
1832 body: serde_json::from_slice(&raw.body).ok(),
1833 })
1834}
1835
1836fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1837 if (200..300).contains(&r.status) {
1838 return Ok(r.body);
1839 }
1840 ensure_ok(
1841 HubResponse {
1842 status: r.status,
1843 body: serde_json::from_slice(&r.body).ok(),
1844 },
1845 what,
1846 )
1847 .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1848}
1849
1850fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1855 matches!(
1856 kind,
1857 ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1858 )
1859}
1860
1861fn with_connect_retries(
1862 mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1863) -> Result<ureq::Response, Box<ureq::Error>> {
1864 let mut attempt = 0;
1865 loop {
1866 match send() {
1867 Err(error)
1868 if matches!(
1869 error.as_ref(),
1870 ureq::Error::Transport(transport)
1871 if is_pre_request_transport(transport.kind())
1872 ) && attempt + 1 < CONNECT_ATTEMPTS =>
1873 {
1874 std::thread::sleep(std::time::Duration::from_millis(
1875 CONNECT_RETRY_BACKOFF_MS[attempt],
1876 ));
1877 attempt += 1;
1878 }
1879 result => return result,
1880 }
1881 }
1882}
1883
1884fn hub_is_loopback(hub: &str) -> bool {
1885 url::Url::parse(hub).ok().is_some_and(|parsed| {
1886 parsed.host().is_some_and(|host| match host {
1887 url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1888 url::Host::Ipv4(ip) => ip.is_loopback(),
1889 url::Host::Ipv6(ip) => ip.is_loopback(),
1890 })
1891 })
1892}
1893
1894fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1898 let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1899 message: "the hub returned an invalid object-store URL".to_string(),
1900 })?;
1901 let allow_private = hub_is_loopback(&cfg.hub)
1902 || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1903 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1904 || !parsed.username().is_empty()
1905 || parsed.password().is_some()
1906 || parsed.fragment().is_some()
1907 {
1908 return Err(LinkError::InvalidPack {
1909 message: "the hub returned an unsafe object-store URL".to_string(),
1910 });
1911 }
1912 Ok((parsed, allow_private))
1913}
1914
1915fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1916 let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1917 pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1918 LinkError::InvalidPack {
1919 message: "the hub returned an object-store URL with an unsafe network target"
1920 .to_string(),
1921 }
1922 })
1923}
1924
1925fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1934 let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1935 let authority = (
1936 first.host_str()?.to_string(),
1937 first.port_or_known_default()?,
1938 );
1939 for raw in &urls[1..] {
1940 let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1941 if (parsed.host_str()?, parsed.port_or_known_default()?)
1942 != (authority.0.as_str(), authority.1)
1943 {
1944 return None;
1945 }
1946 }
1947 pinned_public_agent_pooled(
1948 &first,
1949 allow_private,
1950 "object-store URL",
1951 AgentShape {
1952 idle_per_host: V2_UPLOAD_CONCURRENCY,
1953 ..AgentShape::default()
1954 },
1955 )
1956 .ok()
1957}
1958
1959fn object_store_transport_error(error: ureq::Transport) -> LinkError {
1965 LinkError::Transport {
1966 hub: "the object store".to_string(),
1967 message: format!("network error ({:?})", error.kind()),
1968 }
1969}
1970
1971fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1972 let http = presigned_agent(cfg, raw)?;
1973 let deadline = std::time::Instant::now()
1974 .checked_add(std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS))
1975 .ok_or_else(upload_deadline_error)?;
1976 let mut attempt = 0;
1977 let result = loop {
1978 let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
1982 if let Some(map) = headers.as_object() {
1983 for (name, value) in map {
1984 if let Some(value) = value.as_str() {
1985 req = req.set(name, value);
1986 }
1987 }
1988 }
1989 match req.send_bytes(bytes) {
1990 Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
1996 attempt += 1;
1997 }
1998 Err(ureq::Error::Status(status, _))
1999 if status != 412
2000 && is_retryable_upload_status(status)
2001 && wait_for_upload_retry(deadline, attempt) =>
2002 {
2003 attempt += 1;
2004 }
2005 result => break result,
2006 }
2007 };
2008 match result {
2009 Ok(resp) if (200..300).contains(&resp.status()) => {
2010 drain_presigned_response(resp);
2011 Ok(())
2012 }
2013 Ok(resp) => Err(presigned_upload_refusal(resp)),
2014 Err(error) => match error {
2015 ureq::Error::Status(412, _) => Ok(()),
2020 ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
2021 ureq::Error::Transport(err) => Err(object_store_transport_error(err)),
2022 },
2023 }
2024}
2025
2026fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
2035 let mut buf = Vec::new();
2036 response
2037 .into_reader()
2038 .take(limit)
2039 .read_to_end(&mut buf)
2040 .map_err(|error| LinkError::Transport {
2041 hub: peer.to_string(),
2042 message: error.to_string(),
2043 })?;
2044 Ok(buf)
2045}
2046
2047fn drain_presigned_response(response: ureq::Response) {
2052 let mut reader = response.into_reader().take(64 * 1024);
2053 let _ = std::io::copy(&mut reader, &mut std::io::sink());
2054}
2055
2056fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
2059 let status = response.status();
2060 let detail = response
2061 .into_string()
2062 .ok()
2063 .map(|body| body.chars().take(400).collect::<String>())
2064 .filter(|body| !body.trim().is_empty());
2065 LinkError::Http {
2066 what: "pack upload",
2067 status,
2068 message: match detail {
2069 Some(body) => format!(
2070 "object store rejected the upload: {}",
2071 body.replace('\n', " ")
2072 ),
2073 None => "object store rejected the upload".to_string(),
2074 },
2075 code: None,
2076 details: None,
2077 }
2078}
2079
2080fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
2081 max_bytes.checked_add(1)
2082}
2083
2084fn presigned_download_read_limit() -> u64 {
2085 one_past_bounded_limit(MAX_PACK_BYTES)
2086 .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
2087}
2088
2089fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
2090 let http = presigned_agent(cfg, raw)?;
2091 let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
2092 Ok(resp) => resp,
2093 Err(error) => match *error {
2094 ureq::Error::Status(_, resp) => {
2095 return Err(LinkError::Http {
2096 what: "pack download",
2097 status: resp.status(),
2098 message: "object store rejected the download".to_string(),
2099 code: None,
2100 details: None,
2101 });
2102 }
2103 ureq::Error::Transport(err) => {
2104 return Err(LinkError::Transport {
2105 hub: "the object store".to_string(),
2106 message: err.to_string(),
2107 });
2108 }
2109 },
2110 };
2111 if !(200..300).contains(&resp.status()) {
2112 return Err(LinkError::Http {
2113 what: "pack download",
2114 status: resp.status(),
2115 message: "object store rejected the download".to_string(),
2116 code: None,
2117 details: None,
2118 });
2119 }
2120 let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
2121 if bytes.len() as u64 > MAX_PACK_BYTES {
2122 return Err(LinkError::InvalidPack {
2123 message: "download exceeds the compressed-size limit".to_string(),
2124 });
2125 }
2126 Ok(bytes)
2127}
2128
2129fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
2133 if !(200..300).contains(&r.status) {
2134 let message = r
2135 .body
2136 .as_ref()
2137 .and_then(|b| b.get("error"))
2138 .and_then(Value::as_str)
2139 .unwrap_or("unknown error")
2140 .to_string();
2141 let code = r
2142 .body
2143 .as_ref()
2144 .and_then(|b| b.get("code"))
2145 .and_then(Value::as_str)
2146 .map(str::to_string);
2147 let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
2148 return Err(LinkError::Http {
2149 what,
2150 status: r.status,
2151 message,
2152 code,
2153 details,
2154 });
2155 }
2156 r.body.ok_or(LinkError::NotJson {
2157 what,
2158 status: r.status,
2159 })
2160}
2161
2162fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2171 match ip {
2172 std::net::IpAddr::V4(ip) => {
2173 let [a, b, c, _] = ip.octets();
2174 !(a == 0
2175 || a == 10
2176 || a == 127
2177 || (a == 100 && (64..=127).contains(&b))
2178 || (a == 169 && b == 254)
2179 || (a == 172 && (16..=31).contains(&b))
2180 || (a == 192 && b == 0 && c == 0)
2181 || (a == 192 && b == 0 && c == 2)
2182 || (a == 192 && b == 88 && c == 99)
2183 || (a == 192 && b == 168)
2184 || (a == 198 && (b == 18 || b == 19))
2185 || (a == 198 && b == 51 && c == 100)
2186 || (a == 203 && b == 0 && c == 113)
2187 || a >= 224)
2188 }
2189 std::net::IpAddr::V6(ip) => {
2190 let segments = ip.segments();
2191 (segments[0] & 0xe000) == 0x2000
2196 && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2197 && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2198 && segments[0] != 0x2002
2199 && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2200 }
2201 }
2202}
2203
2204#[derive(Clone)]
2205struct PinnedRegistryResolver {
2206 netloc: String,
2207 addresses: Vec<std::net::SocketAddr>,
2208}
2209
2210impl ureq::Resolver for PinnedRegistryResolver {
2211 fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2212 if requested == self.netloc {
2213 Ok(self.addresses.clone())
2214 } else {
2215 Err(std::io::Error::new(
2216 std::io::ErrorKind::PermissionDenied,
2217 "registry request attempted to resolve an unvalidated authority",
2218 ))
2219 }
2220 }
2221}
2222
2223fn pinned_public_agent(
2224 url: &url::Url,
2225 allow_private: bool,
2226 label: &str,
2227) -> LinkResult<ureq::Agent> {
2228 pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2229}
2230
2231struct AgentShape {
2236 idle_per_host: usize,
2237 overall: std::time::Duration,
2238}
2239
2240impl Default for AgentShape {
2241 fn default() -> Self {
2242 Self {
2243 idle_per_host: 1,
2244 overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2245 }
2246 }
2247}
2248
2249fn pinned_public_agent_pooled(
2250 url: &url::Url,
2251 allow_private: bool,
2252 label: &str,
2253 shape: AgentShape,
2254) -> LinkResult<ureq::Agent> {
2255 let host = url
2256 .host_str()
2257 .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2258 let port = url
2259 .port_or_known_default()
2260 .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2261 let addresses = resolve_addresses_with_deadline(
2262 host,
2263 port,
2264 std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2265 )
2266 .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2267 if addresses.is_empty() {
2268 return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2269 }
2270 if !allow_private
2271 && addresses
2272 .iter()
2273 .any(|address| !is_public_registry_ip(address.ip()))
2274 {
2275 return Err(invalid_feed(format!(
2276 "{label} resolves to a non-public address"
2277 )));
2278 }
2279 let netloc = if host.contains(':') {
2280 format!("[{host}]:{port}")
2281 } else {
2282 format!("{host}:{port}")
2283 };
2284 Ok(agent_builder_with_timeout(shape.overall)
2285 .max_idle_connections_per_host(shape.idle_per_host.max(1))
2286 .resolver(PinnedRegistryResolver { netloc, addresses })
2287 .build())
2288}
2289
2290fn resolve_addresses_with_deadline(
2295 host: &str,
2296 port: u16,
2297 timeout: std::time::Duration,
2298) -> std::io::Result<Vec<std::net::SocketAddr>> {
2299 use std::net::ToSocketAddrs as _;
2300
2301 let host = host.to_string();
2302 let (send, receive) = std::sync::mpsc::sync_channel(1);
2303 std::thread::Builder::new()
2304 .name("dbmd-dns".to_string())
2305 .spawn(move || {
2306 let result = (host.as_str(), port)
2307 .to_socket_addrs()
2308 .map(|addresses| addresses.collect());
2309 let _ = send.send(result);
2310 })
2311 .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2312 match receive.recv_timeout(timeout) {
2313 Ok(result) => result,
2314 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2315 std::io::ErrorKind::TimedOut,
2316 "resolution exceeded its deadline",
2317 )),
2318 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2319 "resolver stopped without returning a result",
2320 )),
2321 }
2322}
2323
2324fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2325 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2326 pinned_public_agent(url, allow_private, "registry home")
2327}
2328
2329fn get_json_absolute(url: &str) -> LinkResult<Value> {
2334 let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2335 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2336 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2337 || !parsed.username().is_empty()
2338 || parsed.password().is_some()
2339 || parsed.query().is_some()
2340 || parsed.fragment().is_some()
2341 {
2342 return Err(invalid_feed("unsafe registry home URL"));
2343 }
2344 let http = registry_agent(&parsed)?;
2345 let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2346 Ok(resp) => resp,
2347 Err(error) => match *error {
2348 ureq::Error::Status(status, resp) => {
2349 let _ = resp;
2350 return Err(LinkError::Http {
2351 what: "registry home fetch",
2352 status,
2353 message: "the home node rejected the card request".to_string(),
2354 code: None,
2355 details: None,
2356 });
2357 }
2358 ureq::Error::Transport(err) => {
2359 return Err(LinkError::Transport {
2360 hub: url.to_string(),
2361 message: err.to_string(),
2362 });
2363 }
2364 },
2365 };
2366 if !(200..300).contains(&resp.status()) {
2367 return Err(LinkError::Http {
2368 what: "registry home fetch",
2369 status: resp.status(),
2370 message: "the home node returned a redirect or error".to_string(),
2371 code: None,
2372 details: None,
2373 });
2374 }
2375 let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2376 if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2377 return Err(LinkError::ResponseTooLarge {
2378 limit_bytes: MAX_REGISTRY_CARD_BYTES,
2379 });
2380 }
2381 serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2382 message: "the home node returned invalid JSON".to_string(),
2383 })
2384}
2385
2386pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2393 require_safe_ref(handle)?;
2394 let trust_directory = open_trust_dir(cfg)?;
2398 let reg = request_capped(
2399 cfg,
2400 "GET",
2401 &format!("/api/hub/registry/{handle}"),
2402 None,
2403 Auth::None,
2404 MAX_REGISTRY_CARD_BYTES,
2405 )?;
2406 if reg.status == 404 {
2407 return Ok(None);
2408 }
2409 let body = ensure_ok(reg, "registry resolve")?;
2410 let home = body
2411 .get("home")
2412 .and_then(Value::as_str)
2413 .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2414 let brain = body
2415 .get("brain")
2416 .and_then(Value::as_str)
2417 .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2418 if !crate::ulid::is_ulid(brain) {
2419 return Err(invalid_feed(
2420 "registry entry brain is not a canonical lowercase ULID",
2421 ));
2422 }
2423 let want_fp = body
2424 .get("identity")
2425 .and_then(|i| i.get("fingerprint"))
2426 .and_then(Value::as_str)
2427 .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2428
2429 let home = home.trim_end_matches('/');
2430 let origin = normalized_origin(home)?;
2431 if origin != home {
2432 return Err(invalid_feed(
2433 "registry home must be an origin without a path, query, or fragment",
2434 ));
2435 }
2436 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2437 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2438 if let Some(binding) = &alias_binding {
2439 if binding
2440 .home
2441 .as_deref()
2442 .is_some_and(|pinned_home| pinned_home != home)
2443 {
2444 return Err(invalid_feed(
2445 "registry relocated a pinned handle to a different home",
2446 ));
2447 }
2448 }
2449 let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2450 if card.get("id").and_then(Value::as_str) != Some(brain) {
2451 return Err(invalid_feed(
2452 "the home node served a card for a different brain",
2453 ));
2454 }
2455 let identity: FeedIdentity = serde_json::from_value(
2456 card.get("identity")
2457 .cloned()
2458 .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2459 )
2460 .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2461 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2462 let got_fp = card
2463 .get("identity")
2464 .and_then(|i| i.get("fingerprint"))
2465 .and_then(Value::as_str)
2466 .unwrap_or_default();
2467 if got_fp != want_fp {
2468 return Err(invalid_feed(
2469 "the home node served an identity that does not match the registry — refusing",
2470 ));
2471 }
2472 let current = format!("ed25519:{}", identity.fingerprint);
2473 let advertised_seq = card
2474 .get("headSeq")
2475 .and_then(Value::as_u64)
2476 .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2477 let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2478 if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2479 || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2480 {
2481 return Err(invalid_feed(
2482 "the home node served an invalid feed head boundary",
2483 ));
2484 }
2485 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2489 let registry_alias = AliasBinding {
2490 v: 1,
2491 origin: normalized_origin(&cfg.hub)?,
2492 requested: handle.to_string(),
2493 brain: brain.to_string(),
2494 home: Some(home.to_string()),
2495 };
2496 save_canonical_pin_and_alias(
2497 cfg,
2498 &trust_directory,
2499 handle,
2500 brain,
2501 TrustState {
2502 v: 2,
2503 origin: normalized_origin(&cfg.hub)?,
2504 requested: brain.to_string(),
2505 brain: brain.to_string(),
2506 home: None,
2507 anchor,
2508 current,
2509 head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2510 feed_hash: pinned
2511 .as_ref()
2512 .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2513 rotations: identity.rotations.clone(),
2514 hub_signer: None,
2515 protocol_profile: None,
2516 },
2517 Some(®istry_alias),
2518 )?;
2519 let mut out = card;
2520 if let Value::Object(map) = &mut out {
2521 map.insert("home".to_string(), Value::String(home.to_string()));
2522 map.insert(
2523 "resolvedVia".to_string(),
2524 Value::String("registry".to_string()),
2525 );
2526 }
2527 Ok(Some(out))
2528}
2529
2530pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2531 require_safe_ref(&addr.brain)?;
2535 if let Some(target) = &addr.target {
2536 let (given, ok) = match target {
2537 AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2538 AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2539 };
2540 if !ok {
2541 return Err(LinkError::BadAddress {
2542 given: given.clone(),
2543 reason: BAD_TARGET_REASON.to_string(),
2544 });
2545 }
2546 }
2547
2548 if let Some(target) = &addr.target {
2554 if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2555 let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2556 what: "resolve",
2557 status: 404,
2558 message: "record not found".to_string(),
2559 code: Some("NOT_FOUND".to_string()),
2560 details: None,
2561 })?;
2562 let (path, file) = match target {
2563 AddressTarget::Path(path) => {
2564 let file =
2565 v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2566 LinkError::Http {
2567 what: "resolve",
2568 status: 404,
2569 message: "record not found".to_string(),
2570 code: Some("NOT_FOUND".to_string()),
2571 details: None,
2572 }
2573 })?;
2574 (path.clone(), file)
2575 }
2576 AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2577 };
2578 let mut downloaded =
2579 download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2580 let (_, bytes) = downloaded
2581 .pop()
2582 .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2583 let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2584 accept_v2_head(cfg, &head)?;
2585 return Ok(resolved);
2586 }
2587 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2588 if !remote.head.verified {
2589 return Err(invalid_feed(
2590 "a path-scoped feed cannot prove a record against the full signed snapshot",
2591 ));
2592 }
2593 if remote.head.seq == 0 {
2594 return Err(LinkError::Http {
2595 what: "resolve",
2596 status: 404,
2597 message: "record not found".to_string(),
2598 code: Some("NOT_FOUND".to_string()),
2599 details: None,
2600 });
2601 }
2602 let brain = remote.head.brain.clone();
2603 let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2604 return resolve_from_verified_pack(&brain, target, pack);
2605 }
2606
2607 let path = format!("/api/hub/brains/{}", addr.brain);
2608 let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2613 if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2614 if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2615 return Ok(card);
2616 }
2617 }
2618 let mut resolved = ensure_ok(direct, "resolve")?;
2619 if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2620 let v2 = v2_verified_head(cfg, &addr.brain)?
2621 .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2622 if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2623 return Err(invalid_feed(
2624 "resolve card is not bound to the verified v2 brain",
2625 ));
2626 }
2627 let card_identity: FeedIdentity = serde_json::from_value(
2628 resolved
2629 .get("identity")
2630 .cloned()
2631 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2632 )
2633 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2634 if card_identity != v2_identity(&v2.identity) {
2635 return Err(invalid_feed(
2636 "resolve card identity differs from the verified v2 identity",
2637 ));
2638 }
2639 accept_v2_head(cfg, &v2)?;
2640 if let Value::Object(card) = &mut resolved {
2641 card.insert(
2642 "headSeq".to_string(),
2643 json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2644 );
2645 card.insert(
2646 "feedHash".to_string(),
2647 v2.pointer
2648 .as_ref()
2649 .map(|pointer| Value::String(pointer.feed_hash.clone()))
2650 .unwrap_or(Value::Null),
2651 );
2652 card.insert(
2653 "storageProfile".to_string(),
2654 Value::String("v2".to_string()),
2655 );
2656 if let Some(pointer) = &v2.pointer {
2657 card.insert(
2658 "updatedAt".to_string(),
2659 Value::String(pointer.signed_at.clone()),
2660 );
2661 }
2662 }
2663 return Ok(resolved);
2664 }
2665 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2669 if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2670 || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2671 || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2672 {
2673 return Err(invalid_feed(
2674 "resolve card is not bound to the exact verified feed checkpoint",
2675 ));
2676 }
2677 let card_identity: FeedIdentity = serde_json::from_value(
2678 resolved
2679 .get("identity")
2680 .cloned()
2681 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2682 )
2683 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2684 if remote.identity.as_ref() != Some(&card_identity) {
2685 return Err(invalid_feed(
2686 "resolve card identity differs from the verified feed identity",
2687 ));
2688 }
2689 Ok(resolved)
2690}
2691
2692fn resolve_from_verified_pack(
2697 brain: &str,
2698 target: &AddressTarget,
2699 pack: Vec<u8>,
2700) -> LinkResult<Value> {
2701 let entries = parse_store_pack(pack)?;
2702 let mut matched: Option<(String, Vec<u8>)> = None;
2703
2704 for (path, bytes) in entries {
2705 let is_candidate = match target {
2706 AddressTarget::Path(want) => &path == want,
2707 AddressTarget::Id(_) => {
2708 path.ends_with(".md")
2709 && (path.starts_with("records/") || path.starts_with("sources/"))
2710 }
2711 };
2712 if !is_candidate {
2713 continue;
2714 }
2715 let text = std::str::from_utf8(&bytes)
2716 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2717 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2718 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2719 if let AddressTarget::Id(want) = target {
2720 let frontmatter =
2721 crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2722 .map_err(|_| {
2723 invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2724 })?;
2725 if frontmatter.id.as_deref() != Some(want) {
2726 continue;
2727 }
2728 }
2729 if matched.is_some() {
2730 return Err(invalid_feed(
2731 "signed snapshot contains more than one record for the requested target",
2732 ));
2733 }
2734 matched = Some((path, bytes));
2735 }
2736
2737 let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2738 what: "resolve",
2739 status: 404,
2740 message: "record not found".to_string(),
2741 code: Some("NOT_FOUND".to_string()),
2742 details: None,
2743 })?;
2744 resolve_from_verified_record_bytes(brain, target, path, bytes)
2745}
2746
2747fn resolve_from_verified_record_bytes(
2748 brain: &str,
2749 target: &AddressTarget,
2750 path: String,
2751 bytes: Vec<u8>,
2752) -> LinkResult<Value> {
2753 match target {
2754 AddressTarget::Path(expected) if expected != &path => {
2755 return Err(invalid_feed(
2756 "verified record path differs from the requested path",
2757 ));
2758 }
2759 AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2760 return Err(invalid_feed(
2761 "verified id resolved outside records or sources",
2762 ));
2763 }
2764 _ => {}
2765 }
2766 let text = std::str::from_utf8(&bytes)
2767 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2768 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2769 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2770 let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2771 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2772 let Value::Object(fields) = frontmatter else {
2773 return Err(invalid_feed(format!(
2774 "signed snapshot record `{path}` frontmatter is not a mapping"
2775 )));
2776 };
2777 if let AddressTarget::Id(expected) = target {
2778 if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2779 return Err(invalid_feed(
2780 "verified record id differs from the requested id",
2781 ));
2782 }
2783 }
2784 let mut document = serde_json::Map::new();
2785 document.insert("path".to_string(), Value::String(path));
2786 for (key, value) in fields {
2787 document.insert(key, value);
2788 }
2789 document.insert("body".to_string(), Value::String(parsed.body));
2790 document.insert(
2791 "contentSha".to_string(),
2792 Value::String(content_sha256(&bytes)),
2793 );
2794 Ok(json!({
2795 "brain": brain,
2796 "document": Value::Object(document),
2797 }))
2798}
2799
2800#[derive(Debug, Clone, serde::Serialize)]
2806pub struct PullReport {
2807 pub brain: String,
2809 pub slug: String,
2811 #[serde(rename = "headSeq")]
2813 pub head_seq: u64,
2814 pub files: usize,
2816 pub dest: String,
2818 #[serde(rename = "extraLocal")]
2821 pub extra_local: Vec<String>,
2822 #[serde(rename = "syncStatus")]
2824 pub sync_status: String,
2825}
2826
2827struct V2PulledSnapshot {
2828 report: PullReport,
2829 head: V2VerifiedHead,
2830 files: std::collections::BTreeMap<String, V2BaselineFile>,
2831 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2832 local: V2LocalView,
2833 local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2834}
2835
2836fn download_verified_snapshot_pack(
2837 cfg: &HubConfig,
2838 brain: &str,
2839 remote: &VerifiedRemote,
2840) -> LinkResult<Vec<u8>> {
2841 let feed_hash = remote
2842 .head
2843 .feed_hash
2844 .as_deref()
2845 .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2846 let signed_head = remote
2847 .head_entry
2848 .as_ref()
2849 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2850 let expected = &signed_head.entry.pack_sha256;
2851 if !is_sha256(expected) {
2852 return Err(invalid_feed(
2853 "signed head carries an invalid snapshot pack digest",
2854 ));
2855 }
2856 let path = format!(
2857 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2858 remote.head.seq
2859 );
2860 let body = ensure_ok(
2861 request(cfg, "GET", &path, None, Auth::Required)?,
2862 "sync pull",
2863 )?;
2864 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2865 || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2866 || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2867 || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2868 {
2869 return Err(invalid_feed(
2870 "export response is not bound to the exact verified snapshot",
2871 ));
2872 }
2873 let url = body
2874 .get("url")
2875 .and_then(Value::as_str)
2876 .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2877 let bytes = get_presigned(cfg, url)?;
2878 if content_sha256(&bytes) != *expected {
2879 return Err(LinkError::InvalidPack {
2880 message: "downloaded pack does not match the signed snapshot digest".to_string(),
2881 });
2882 }
2883 let entries = parse_store_pack(bytes.clone())?;
2884 if signed_head.entry.kind == "push" {
2885 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2886 }
2887 Ok(bytes)
2888}
2889
2890#[derive(Debug, Clone, Deserialize, Serialize)]
2891struct V2PointerBody {
2892 v: u8,
2893 brain: String,
2894 seq: u64,
2895 commit_hash: String,
2896 feed_hash: String,
2897 content_root: Option<String>,
2898 asset_root: Option<String>,
2899 materializer: String,
2900 signer_epoch: u64,
2901 control_revision: String,
2902 backup_preparation: String,
2903 prior_pointer_hash: Option<String>,
2904 signed_at: String,
2905}
2906
2907#[derive(Debug, Clone, Deserialize)]
2908struct V2SignedPointer {
2909 pointer: V2PointerBody,
2910 hub_public_key: String,
2911 hub_fingerprint: String,
2912 sig: String,
2913}
2914
2915#[derive(Debug, Clone, Deserialize)]
2916struct V2HeadIdentity {
2917 #[serde(default)]
2918 custody: String,
2919 fingerprint: String,
2920 public_key_spki: String,
2921 #[serde(default)]
2922 previous: Vec<V2PreviousIdentity>,
2923 #[serde(default)]
2924 rotations: Vec<String>,
2925}
2926
2927#[derive(Debug, Clone, Deserialize)]
2928struct V2PreviousIdentity {
2929 fingerprint: String,
2930 public_key_spki: String,
2931}
2932
2933#[derive(Debug, Deserialize)]
2934struct V2HeadResponse {
2935 v: u8,
2936 brain_id: String,
2937 profile: String,
2938 view: Option<V2HeadView>,
2939 pointer: Option<V2SignedPointer>,
2940 identity: Option<V2HeadIdentity>,
2941}
2942
2943#[derive(Debug, Clone, Deserialize)]
2944struct V2HeadView {
2945 kind: String,
2946 #[serde(default)]
2947 id: Option<String>,
2948 control_revision: String,
2949}
2950
2951#[derive(Debug, Clone)]
2952struct V2VerifiedHead {
2953 requested: String,
2954 brain_id: String,
2955 view_kind: String,
2956 view_revision: String,
2958 control_revision: String,
2960 identity: V2HeadIdentity,
2961 pointer: Option<V2PointerBody>,
2962 trust: TrustState,
2963 alias: Option<AliasBinding>,
2964}
2965
2966fn verify_v2_spki_signature(
2967 public_key: &str,
2968 message: &[u8],
2969 signature: &str,
2970) -> LinkResult<Vec<u8>> {
2971 let der = URL_SAFE_NO_PAD
2972 .decode(public_key)
2973 .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2974 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2975 return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2976 }
2977 let sig = URL_SAFE_NO_PAD
2978 .decode(signature)
2979 .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2980 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2981 .verify(message, &sig)
2982 .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2983 Ok(der)
2984}
2985
2986fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2987 if pointer.pointer.v != 2
2988 || pointer.pointer.brain != expected_brain
2989 || pointer.pointer.seq == 0
2990 || !is_sha256(&pointer.pointer.commit_hash)
2991 || !is_sha256(&pointer.pointer.feed_hash)
2992 || pointer
2993 .pointer
2994 .content_root
2995 .as_deref()
2996 .is_some_and(|hash| !is_sha256(hash))
2997 || !is_sha256(&pointer.pointer.backup_preparation)
2998 {
2999 return Err(invalid_feed("v2 pointer fields are invalid"));
3000 }
3001 let value = serde_json::to_value(&pointer.pointer)
3002 .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
3003 let message = crate::linkmd_v2::canonical_bytes(&value)
3004 .map_err(|error| invalid_feed(error.to_string()))?;
3005 let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
3006 let fingerprint = format!("{:x}", Sha256::digest(&der));
3007 if fingerprint != pointer.hub_fingerprint {
3008 return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
3009 }
3010 Ok(format!(
3011 "{}:{}",
3012 pointer.hub_fingerprint, pointer.hub_public_key
3013 ))
3014}
3015
3016fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
3017 FeedIdentity {
3018 fingerprint: identity.fingerprint.clone(),
3019 public_key_spki: identity.public_key_spki.clone(),
3020 previous: identity
3021 .previous
3022 .iter()
3023 .map(|previous| PreviousIdentity {
3024 fingerprint: previous.fingerprint.clone(),
3025 public_key_spki: previous.public_key_spki.clone(),
3026 })
3027 .collect(),
3028 rotations: identity.rotations.clone(),
3029 }
3030}
3031
3032fn verified_v2_commit_object(
3033 raw: &[u8],
3034 identity: &V2HeadIdentity,
3035) -> LinkResult<serde_json::Map<String, Value>> {
3036 let mut value: Value =
3037 serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
3038 let canonical = crate::linkmd_v2::canonical_bytes(&value)
3039 .map_err(|error| invalid_feed(error.to_string()))?;
3040 if canonical != raw {
3041 return Err(invalid_feed("v2 commit is not canonical JSON"));
3042 }
3043 let object = value
3044 .as_object_mut()
3045 .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
3046 let sig = object
3047 .remove("sig")
3048 .and_then(|value| value.as_str().map(str::to_string))
3049 .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
3050 const FIELDS: [&str; 18] = [
3051 "actor_ref",
3052 "asset_root",
3053 "brain",
3054 "changes_sha256",
3055 "control_revision",
3056 "materializer",
3057 "op",
3058 "parent_asset_root",
3059 "parent_commit",
3060 "parent_root",
3061 "prev_entry_hash",
3062 "public_key",
3063 "seq",
3064 "signer_epoch",
3065 "state_root",
3066 "ts",
3067 "v",
3068 "v1_bridge",
3069 ];
3070 if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
3071 return Err(invalid_feed("v2 commit has a non-normative field set"));
3072 }
3073 let seq = object
3074 .get("seq")
3075 .and_then(Value::as_u64)
3076 .filter(|seq| *seq > 0)
3077 .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
3078 let signer_epoch = object
3079 .get("signer_epoch")
3080 .and_then(Value::as_u64)
3081 .filter(|epoch| *epoch > 0)
3082 .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
3083 let hash_or_null = |field: &str| {
3084 object
3085 .get(field)
3086 .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
3087 };
3088 if object.get("v").and_then(Value::as_u64) != Some(2)
3089 || object.get("op").and_then(Value::as_str) != Some("changeset")
3090 || !object
3091 .get("changes_sha256")
3092 .and_then(Value::as_str)
3093 .is_some_and(is_sha256)
3094 || !object
3095 .get("actor_ref")
3096 .and_then(Value::as_str)
3097 .is_some_and(is_sha256)
3098 || !object
3099 .get("control_revision")
3100 .and_then(Value::as_str)
3101 .is_some_and(is_sha256)
3102 || !object
3103 .get("state_root")
3104 .and_then(Value::as_str)
3105 .is_some_and(is_sha256)
3106 || !hash_or_null("parent_commit")
3107 || !hash_or_null("parent_root")
3108 || !hash_or_null("parent_asset_root")
3109 || !hash_or_null("asset_root")
3110 || !hash_or_null("prev_entry_hash")
3111 || !object
3112 .get("materializer")
3113 .and_then(Value::as_str)
3114 .is_some_and(|value| !value.is_empty() && value.len() <= 128)
3115 || !object
3116 .get("ts")
3117 .and_then(Value::as_str)
3118 .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
3119 {
3120 return Err(invalid_feed("v2 commit fields are invalid"));
3121 }
3122 if (seq == 1
3123 && [
3124 "parent_commit",
3125 "parent_root",
3126 "parent_asset_root",
3127 "prev_entry_hash",
3128 ]
3129 .iter()
3130 .any(|field| !object.get(*field).is_some_and(Value::is_null)))
3131 || (seq > 1
3132 && ["parent_commit", "parent_root", "prev_entry_hash"]
3133 .iter()
3134 .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
3135 {
3136 return Err(invalid_feed("v2 commit parent shape is invalid"));
3137 }
3138 match object.get("v1_bridge") {
3139 Some(Value::Null) => {}
3140 Some(Value::Object(bridge))
3141 if seq == 1
3142 && bridge.len() == 3
3143 && bridge
3144 .get("head_seq")
3145 .and_then(Value::as_u64)
3146 .is_some_and(|v| v > 0)
3147 && bridge
3148 .get("feed_hash")
3149 .and_then(Value::as_str)
3150 .is_some_and(is_sha256)
3151 && bridge
3152 .get("pack_sha256")
3153 .and_then(Value::as_str)
3154 .is_some_and(is_sha256) => {}
3155 _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
3156 }
3157 let public_key = object
3158 .get("public_key")
3159 .and_then(Value::as_str)
3160 .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3161 let der = URL_SAFE_NO_PAD
3162 .decode(public_key)
3163 .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3164 let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3165 if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3166 return Err(invalid_feed("v2 commit brain identity mismatch"));
3167 }
3168 verify_identity_chain(&v2_identity(identity), None)?;
3170 let mut chain: Vec<(&str, &str)> = identity
3173 .previous
3174 .iter()
3175 .rev()
3176 .map(|previous| {
3177 (
3178 previous.fingerprint.as_str(),
3179 previous.public_key_spki.as_str(),
3180 )
3181 })
3182 .collect();
3183 chain.push((&identity.fingerprint, &identity.public_key_spki));
3184 let signer_index = chain.iter().position(|(fingerprint, spki)| {
3185 *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3186 });
3187 let Some(signer_index) = signer_index else {
3188 return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3189 };
3190 if signer_epoch != signer_index as u64 + 1 {
3191 return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3192 }
3193 let lower_boundary = if signer_index == 0 {
3194 None
3195 } else {
3196 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3197 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3198 Some(prior.prior_head_seq)
3199 };
3200 let upper_boundary = if signer_index == identity.rotations.len() {
3201 None
3202 } else {
3203 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3204 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3205 Some(next.prior_head_seq)
3206 };
3207 if lower_boundary.is_some_and(|boundary| seq <= boundary)
3208 || upper_boundary.is_some_and(|boundary| seq > boundary)
3209 {
3210 return Err(invalid_feed(
3211 "v2 commit signer is outside its authenticated rotation epoch",
3212 ));
3213 }
3214 let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3215 .map_err(|error| invalid_feed(error.to_string()))?;
3216 verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3217 Ok(object.clone())
3218}
3219
3220#[derive(Debug, Deserialize)]
3221struct V2FeedWireEntry {
3222 seq: u64,
3223 commit_hash: String,
3224 feed_hash: String,
3225 bytes_base64: String,
3226}
3227
3228#[derive(Debug, Deserialize)]
3229struct V2FeedPage {
3230 v: u8,
3231 head_seq: u64,
3232 head_commit_hash: String,
3233 head_feed_hash: String,
3234 entries: Vec<V2FeedWireEntry>,
3235 next_after: u64,
3236 complete: bool,
3237}
3238
3239fn replay_v2_feed(
3240 cfg: &HubConfig,
3241 brain: &str,
3242 pointer: &V2PointerBody,
3243 identity: &V2HeadIdentity,
3244 start_after: u64,
3245 start_feed: Option<String>,
3246) -> LinkResult<()> {
3247 let mut after = start_after;
3248 let mut prior_feed = start_feed;
3249 let mut final_object = None;
3250 let mut replayed_entries = 0_u64;
3251 let mut replayed_bytes = 0_u64;
3252 while after < pointer.seq {
3253 let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3254 let value = ensure_ok(
3255 request_capped(
3256 cfg,
3257 "GET",
3258 &path,
3259 None,
3260 Auth::Required,
3261 MAX_FEED_REPLAY_BYTES,
3262 )?,
3263 "v2 feed replay",
3264 )?;
3265 let page: V2FeedPage = serde_json::from_value(value)
3266 .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3267 if page.v != 2
3268 || page.head_seq != pointer.seq
3269 || page.head_commit_hash != pointer.commit_hash
3270 || page.head_feed_hash != pointer.feed_hash
3271 || page.entries.is_empty()
3272 || page.entries.len() > FEED_PAGE_LIMIT
3273 {
3274 return Err(invalid_feed("v2 feed page differs from the signed head"));
3275 }
3276 for entry in page.entries {
3277 if entry.seq != after + 1
3278 || !is_sha256(&entry.commit_hash)
3279 || !is_sha256(&entry.feed_hash)
3280 {
3281 return Err(invalid_feed("v2 feed sequence is not contiguous"));
3282 }
3283 let raw = base64::engine::general_purpose::STANDARD
3284 .decode(&entry.bytes_base64)
3285 .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3286 replayed_entries = replayed_entries
3287 .checked_add(1)
3288 .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3289 replayed_bytes = replayed_bytes
3290 .checked_add(raw.len() as u64)
3291 .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3292 if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3293 {
3294 return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3295 }
3296 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3297 .map_err(|error| invalid_feed(error.to_string()))?
3298 != entry.commit_hash
3299 || content_sha256(&raw) != entry.feed_hash
3300 {
3301 return Err(invalid_feed("v2 feed entry address mismatch"));
3302 }
3303 let object = verified_v2_commit_object(&raw, identity)?;
3304 if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3305 || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3306 {
3307 return Err(invalid_feed(
3308 "v2 feed entry does not extend its predecessor",
3309 ));
3310 }
3311 after = entry.seq;
3312 prior_feed = Some(entry.feed_hash);
3313 final_object = Some((entry.commit_hash, object));
3314 }
3315 if page.next_after != after || (page.complete != (after == pointer.seq)) {
3316 return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3317 }
3318 }
3319 let (final_hash, object) =
3320 final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3321 if final_hash != pointer.commit_hash
3322 || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3323 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3324 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3325 || object.get("control_revision").and_then(Value::as_str)
3326 != Some(pointer.control_revision.as_str())
3327 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3328 {
3329 return Err(invalid_feed(
3330 "v2 replay did not converge on the signed pointer",
3331 ));
3332 }
3333 Ok(())
3334}
3335
3336fn verify_v1_to_v2_bridge(
3337 cfg: &HubConfig,
3338 brain: &str,
3339 pointer: &V2PointerBody,
3340 identity: &V2HeadIdentity,
3341 checkpoint: &TrustState,
3342) -> LinkResult<()> {
3343 let value = ensure_ok(
3344 request_capped(
3345 cfg,
3346 "GET",
3347 &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3348 None,
3349 Auth::Required,
3350 MAX_FEED_RESPONSE_BYTES,
3351 )?,
3352 "v2 genesis bridge",
3353 )?;
3354 let page: V2FeedPage = serde_json::from_value(value)
3355 .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3356 if page.v != 2
3357 || page.head_seq != pointer.seq
3358 || page.head_commit_hash != pointer.commit_hash
3359 || page.head_feed_hash != pointer.feed_hash
3360 || page.entries.len() != 1
3361 || page.entries[0].seq != 1
3362 || !is_sha256(&page.entries[0].commit_hash)
3363 || !is_sha256(&page.entries[0].feed_hash)
3364 {
3365 return Err(invalid_feed(
3366 "v2 genesis bridge page differs from the signed head",
3367 ));
3368 }
3369 let first = &page.entries[0];
3370 let raw = STANDARD
3371 .decode(&first.bytes_base64)
3372 .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3373 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3374 .map_err(|error| invalid_feed(error.to_string()))?
3375 != first.commit_hash
3376 || content_sha256(&raw) != first.feed_hash
3377 {
3378 return Err(invalid_feed("v2 genesis bridge address mismatch"));
3379 }
3380 let object = verified_v2_commit_object(&raw, identity)?;
3381 if checkpoint.head_seq == 0 {
3382 if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3383 return Err(invalid_feed(
3384 "empty v1 checkpoint did not transition through an empty v2 genesis",
3385 ));
3386 }
3387 return Ok(());
3388 }
3389 let bridge = object
3390 .get("v1_bridge")
3391 .and_then(Value::as_object)
3392 .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3393 let checkpoint_feed = checkpoint
3394 .feed_hash
3395 .as_deref()
3396 .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3397 if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3398 || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3399 {
3400 return Err(invalid_feed(
3401 "v2 genesis bridge differs from the pinned v1 checkpoint",
3402 ));
3403 }
3404 let legacy_raw = ensure_raw_ok(
3405 request_raw(
3406 cfg,
3407 "GET",
3408 &format!(
3409 "/api/hub/brains/{brain}/feed?after={}&limit=1",
3410 checkpoint.head_seq - 1
3411 ),
3412 None,
3413 Auth::Required,
3414 MAX_FEED_RESPONSE_BYTES,
3415 )?,
3416 "v1 bridge boundary",
3417 )?;
3418 let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3419 .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3420 let legacy_identity = legacy
3421 .identity
3422 .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3423 let item = legacy
3424 .entries
3425 .first()
3426 .filter(|_| legacy.entries.len() == 1)
3427 .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3428 if legacy.scope_limited
3429 || legacy.head_seq != checkpoint.head_seq
3430 || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3431 || item.entry.seq != checkpoint.head_seq
3432 || item.hash != checkpoint_feed
3433 || legacy_identity != v2_identity(identity)
3434 || bridge.get("pack_sha256").and_then(Value::as_str)
3435 != Some(item.entry.pack_sha256.as_str())
3436 {
3437 return Err(invalid_feed(
3438 "v1 bridge boundary differs from its signed legacy head",
3439 ));
3440 }
3441 let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3442 if anchor != checkpoint.anchor {
3443 return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3444 }
3445 verify_feed_item(item, &legacy_identity)?;
3446 verify_rotation_feed_boundaries(
3447 &legacy_identity,
3448 Some(checkpoint),
3449 std::slice::from_ref(item),
3450 checkpoint.head_seq,
3451 )?;
3452 Ok(())
3453}
3454
3455fn verify_v2_commit(
3456 cfg: &HubConfig,
3457 brain: &str,
3458 pointer: &V2PointerBody,
3459 identity: &V2HeadIdentity,
3460 pinned: Option<&TrustState>,
3461) -> LinkResult<()> {
3462 let path = format!(
3463 "/api/hub/brains/{brain}/v2/commit?commit={}",
3464 pointer.commit_hash
3465 );
3466 let raw = ensure_raw_ok(
3467 request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3468 "v2 commit",
3469 )?;
3470 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3471 .map_err(|error| invalid_feed(error.to_string()))?
3472 != pointer.commit_hash
3473 || content_sha256(&raw) != pointer.feed_hash
3474 {
3475 return Err(invalid_feed("v2 commit address differs from the pointer"));
3476 }
3477 let object = verified_v2_commit_object(&raw, identity)?;
3478 if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3479 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3480 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3481 || object.get("control_revision").and_then(Value::as_str)
3482 != Some(pointer.control_revision.as_str())
3483 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3484 {
3485 return Err(invalid_feed("v2 commit fields differ from the pointer"));
3486 }
3487 if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3488 if pointer.seq == checkpoint.head_seq + 1
3489 && object.get("prev_entry_hash").and_then(Value::as_str)
3490 != checkpoint.feed_hash.as_deref()
3491 {
3492 return Err(invalid_feed(
3493 "v2 commit does not extend the pinned feed hash",
3494 ));
3495 }
3496 if pointer.seq > checkpoint.head_seq + 1 {
3497 return replay_v2_feed(
3498 cfg,
3499 brain,
3500 pointer,
3501 identity,
3502 checkpoint.head_seq,
3503 checkpoint.feed_hash.clone(),
3504 );
3505 }
3506 } else {
3507 if let Some(checkpoint) = pinned {
3508 verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3509 }
3510 if pointer.seq > 1 {
3511 return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3512 }
3513 }
3514 Ok(())
3515}
3516
3517fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3518 require_hardened_filesystem("verified link.md v2 state")?;
3519 require_safe_ref(brain)?;
3520 let trust_directory = open_trust_dir(cfg)?;
3524 let path = format!("/api/hub/brains/{brain}/v2/head");
3525 let started = std::time::Instant::now();
3532 let mut wait = std::time::Duration::from_millis(250);
3533 let response = loop {
3534 let response = request(cfg, "GET", &path, None, Auth::Required)?;
3535 if !v2_validation_catching_up(&response) {
3536 break response;
3537 }
3538 if started.elapsed() >= std::time::Duration::from_secs(15 * 60) {
3539 return Err(LinkError::Http {
3540 what: "v2 head",
3541 status: response.status,
3542 message: "validation/index recovery did not reach the durable source head within 15 minutes"
3543 .to_string(),
3544 code: Some("validation_index_catching_up".to_string()),
3545 details: response.body,
3546 });
3547 }
3548 std::thread::sleep(wait);
3549 wait = wait
3550 .saturating_mul(2)
3551 .min(std::time::Duration::from_secs(5));
3552 };
3553 if response.status == 404 {
3554 if has_accepted_v2_ref(cfg, brain)? {
3555 return Err(LinkError::BrainUnavailable);
3556 }
3557 return Ok(None);
3558 }
3559 let body = ensure_ok(response, "v2 head")?;
3560 let head: V2HeadResponse = serde_json::from_value(body)
3561 .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3562 if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3563 return Err(invalid_feed("v2 head has no canonical brain id"));
3564 }
3565 if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3566 return Err(invalid_feed("v2 head resolved a different brain id"));
3567 }
3568 if head.profile == "v1" {
3569 return Ok(None);
3570 }
3571 if head.profile != "v2" && head.profile != "v2-empty" {
3572 return Err(invalid_feed("v2 head advertised an unknown profile"));
3573 }
3574 let view = head
3575 .view
3576 .as_ref()
3577 .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3578 if !matches!(view.kind.as_str(), "full" | "scoped")
3579 || !is_sha256(&view.control_revision)
3580 || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3581 {
3582 return Err(invalid_feed("v2 head has an invalid permission view"));
3583 }
3584 let view_kind = view.kind.clone();
3585 let view_revision = view
3588 .id
3589 .clone()
3590 .unwrap_or_else(|| view.control_revision.clone());
3591 let control_revision = view.control_revision.clone();
3592 let identity = head
3593 .identity
3594 .as_ref()
3595 .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3596 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3597 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3598 let feed_identity = v2_identity(identity);
3599 let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3600 let (seq, feed_hash, hub_signer) = match &head.pointer {
3601 None => {
3602 if head.profile != "v2-empty" {
3603 return Err(invalid_feed("initialized v2 head has no pointer"));
3604 }
3605 (
3606 0,
3607 None,
3608 pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3609 )
3610 }
3611 Some(signed) => {
3612 let signer = verify_v2_pointer(signed, &head.brain_id)?;
3613 if pinned
3614 .as_ref()
3615 .and_then(|state| state.hub_signer.as_ref())
3616 .is_some_and(|known| known != &signer)
3617 {
3618 return Err(invalid_feed(
3619 "v2 hub pointer signer changed without a trust transition",
3620 ));
3621 }
3622 if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3623 if signed.pointer.seq < checkpoint.head_seq
3624 || (signed.pointer.seq == checkpoint.head_seq
3625 && checkpoint.feed_hash.as_deref()
3626 != Some(signed.pointer.feed_hash.as_str()))
3627 {
3628 return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3629 }
3630 }
3631 verify_v2_commit(
3632 cfg,
3633 &head.brain_id,
3634 &signed.pointer,
3635 identity,
3636 pinned.as_ref(),
3637 )?;
3638 (
3639 signed.pointer.seq,
3640 Some(signed.pointer.feed_hash.clone()),
3641 Some(signer),
3642 )
3643 }
3644 };
3645 let trust = TrustState {
3646 v: 2,
3647 origin: normalized_origin(&cfg.hub)?,
3648 requested: head.brain_id.clone(),
3649 brain: head.brain_id.clone(),
3650 home: None,
3651 anchor,
3652 current: format!("ed25519:{}", identity.fingerprint),
3653 head_seq: seq,
3654 feed_hash,
3655 rotations: identity.rotations.clone(),
3656 hub_signer,
3657 protocol_profile: Some("link-v2".to_string()),
3658 };
3659 Ok(Some(V2VerifiedHead {
3660 requested: brain.to_string(),
3661 brain_id: head.brain_id,
3662 view_kind,
3663 view_revision,
3664 control_revision,
3665 identity: identity.clone(),
3666 pointer: head.pointer.map(|signed| signed.pointer),
3667 trust,
3668 alias: alias_binding,
3669 }))
3670}
3671
3672fn v2_validation_catching_up(response: &HubResponse) -> bool {
3673 response.status == 422
3674 && response.body.as_ref().is_some_and(|body| {
3675 body.get("code").and_then(Value::as_str) == Some("validation_index_catching_up")
3676 || body
3677 .get("details")
3678 .and_then(|details| details.get("code"))
3679 .and_then(Value::as_str)
3680 == Some("validation_index_catching_up")
3681 })
3682}
3683
3684fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3685 let directory = open_trust_dir(cfg)?;
3686 let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3687 let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3688 if let Some(current) = current {
3689 let common_invalid = head.trust.anchor != current.anchor
3690 || !head.trust.rotations.starts_with(¤t.rotations);
3691 let profile_invalid = if accepted_as_v2(¤t) {
3692 head.trust.head_seq < current.head_seq
3693 || (head.trust.head_seq == current.head_seq
3694 && head.trust.feed_hash != current.feed_hash)
3695 || current
3696 .hub_signer
3697 .as_ref()
3698 .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3699 } else {
3700 head.trust.protocol_profile.as_deref() != Some("link-v2")
3701 || head.trust.hub_signer.is_none()
3702 };
3703 if common_invalid || profile_invalid {
3704 return Err(invalid_feed(
3705 "v2 head cannot advance the currently accepted trust checkpoint",
3706 ));
3707 }
3708 }
3709 save_canonical_pin_and_alias(
3710 cfg,
3711 &directory,
3712 &head.requested,
3713 &head.brain_id,
3714 head.trust.clone(),
3715 alias.as_ref().or(head.alias.as_ref()),
3716 )
3717}
3718
3719#[derive(Debug, Clone, Deserialize, Serialize)]
3720struct V2BaselineFile {
3721 sha256: String,
3722 bytes: u64,
3723 #[serde(skip)]
3724 proof: Option<Vec<V2ProofStep>>,
3725}
3726
3727#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
3733struct V2ScanCacheFile {
3734 fingerprint: String,
3735 sha256: String,
3736 bytes: u64,
3737 #[serde(default)]
3738 withheld_targets: Vec<String>,
3739}
3740
3741#[derive(Debug, Clone, Deserialize, Serialize)]
3742struct V2SyncBaseline {
3743 v: u8,
3744 origin: String,
3745 brain: String,
3746 #[serde(default)]
3747 checkout_id: Option<String>,
3748 #[serde(default)]
3749 head_seq: Option<u64>,
3750 commit_hash: Option<String>,
3751 content_root: Option<String>,
3752 #[serde(default)]
3753 asset_root: Option<String>,
3754 #[serde(default)]
3755 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3756 #[serde(default)]
3757 view_kind: Option<String>,
3758 #[serde(default)]
3759 view_revision: Option<String>,
3760 #[serde(default)]
3764 control_revision: Option<String>,
3765 #[serde(default)]
3766 projection_sha256: Option<String>,
3767 files: std::collections::BTreeMap<String, V2BaselineFile>,
3768 #[serde(default)]
3769 scan_cache: std::collections::BTreeMap<String, V2ScanCacheFile>,
3770 #[serde(default)]
3771 local_policy_digest: Option<String>,
3772 #[serde(default)]
3773 local_eligibility: std::collections::BTreeMap<String, bool>,
3774 #[serde(default)]
3775 remote_copy_remains: std::collections::BTreeMap<String, String>,
3776}
3777
3778#[derive(Clone)]
3779struct V2LocalView {
3780 riding: std::collections::BTreeMap<String, (String, u64)>,
3781 scan_cache: std::collections::BTreeMap<String, V2ScanCacheFile>,
3782 eligibility: std::collections::BTreeMap<String, bool>,
3783 policy: crate::linkmd_sync_policy::SyncPolicy,
3784 withheld_links: Vec<V2WithheldLink>,
3785}
3786
3787#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3788struct V2WithheldLink {
3789 source: String,
3790 target: String,
3791}
3792
3793#[derive(Debug, Clone, Deserialize, Serialize)]
3794struct V2ProofStep {
3795 directory_root: String,
3796 component: String,
3797 proof: crate::linkmd_v2::HamtProof,
3798}
3799
3800#[derive(Debug, Deserialize)]
3801struct V2ManifestFile {
3802 path: String,
3803 sha256: String,
3804 bytes: u64,
3805 proof: Vec<V2ProofStep>,
3806}
3807
3808#[derive(Debug, Deserialize)]
3809struct V2ManifestPage {
3810 v: u8,
3811 commit: String,
3812 content_root: Option<String>,
3813 files: Vec<V2ManifestFile>,
3814 next_cursor: Option<String>,
3815}
3816
3817#[derive(Debug, Clone, Deserialize, Serialize)]
3818struct V2BaselineAsset {
3819 blob_sha256: String,
3820 bytes: u64,
3821 media_type: String,
3822 wrappers: Vec<String>,
3823 required: bool,
3824 disposition: String,
3825 leaf_hash: String,
3826}
3827
3828#[derive(Debug, Deserialize)]
3829struct V2AssetManifestItem {
3830 path: String,
3831 blob_sha256: String,
3832 bytes: u64,
3833 media_type: String,
3834 wrappers: Vec<String>,
3835 required: bool,
3836 disposition: String,
3837 leaf_hash: String,
3838 proof: crate::linkmd_v2::HamtProof,
3839}
3840
3841#[derive(Debug, Deserialize)]
3842struct V2AssetManifestPage {
3843 v: u8,
3844 commit: String,
3845 asset_root: Option<String>,
3846 assets: Vec<V2AssetManifestItem>,
3847 next_cursor: Option<String>,
3848}
3849
3850#[derive(Debug, Deserialize)]
3851struct V2SigningCandidate {
3852 seq: u64,
3853 content_root: Option<String>,
3854 asset_root: Option<String>,
3855 signing_bytes_base64: String,
3856 changes_base64: String,
3857 actor_claim_base64: String,
3858}
3859
3860#[derive(Debug, Deserialize)]
3861struct V2SigningCandidatePage {
3862 v: u8,
3863 challenge_id: String,
3864 mutation_id: String,
3865 request_hash: String,
3866 parent: V2SigningParent,
3867 candidate: V2SigningCandidate,
3868 files: Vec<V2ManifestFile>,
3869 #[serde(default)]
3870 assets: Vec<V2AssetManifestItem>,
3871 next_cursor: Option<String>,
3872 expires_at: String,
3873}
3874
3875#[derive(Debug, Deserialize)]
3876struct V2SigningParent {
3877 seq: u64,
3878 commit_hash: Option<String>,
3879}
3880
3881fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3882 let normalized = crate::linkmd_v2::normalize_path(&file.path)
3883 .map_err(|error| invalid_feed(error.to_string()))?;
3884 let components = normalized.split('/').collect::<Vec<_>>();
3885 if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3886 return Err(invalid_feed("v2 file proof has the wrong shape"));
3887 }
3888 let mut directory_root = root.to_string();
3889 for (index, step) in file.proof.iter().enumerate() {
3890 if step.directory_root != directory_root || step.component != components[index] {
3891 return Err(invalid_feed(
3892 "v2 file proof path chain differs from its manifest",
3893 ));
3894 }
3895 if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3896 .map_err(|error| invalid_feed(error.to_string()))?
3897 {
3898 return Err(invalid_feed("v2 file proof failed verification"));
3899 }
3900 let entry = match &step.proof {
3901 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3902 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3903 return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3904 }
3905 };
3906 if index + 1 == components.len() {
3907 if entry.kind != crate::linkmd_v2::EntryKind::Blob
3908 || entry.child_hash != file.sha256
3909 || entry.bytes != Some(file.bytes)
3910 {
3911 return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3912 }
3913 } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3914 return Err(invalid_feed("v2 file proof traversed a non-directory"));
3915 } else {
3916 directory_root = entry.child_hash.clone();
3917 }
3918 }
3919 Ok(())
3920}
3921
3922fn v2_manifest(
3923 cfg: &HubConfig,
3924 brain: &str,
3925 pointer: Option<&V2PointerBody>,
3926) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3927 let Some(pointer) = pointer else {
3928 return Ok(std::collections::BTreeMap::new());
3929 };
3930 let Some(root) = pointer.content_root.as_deref() else {
3931 return Ok(std::collections::BTreeMap::new());
3932 };
3933 let mut files = std::collections::BTreeMap::new();
3934 let mut after = String::new();
3935 loop {
3936 let encoded_after: String =
3937 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3938 let path = format!(
3939 "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3940 pointer.commit_hash
3941 );
3942 let value = ensure_ok(
3943 request_capped(
3944 cfg,
3945 "GET",
3946 &path,
3947 None,
3948 Auth::Required,
3949 MAX_FEED_RESPONSE_BYTES,
3950 )?,
3951 "v2 file manifest",
3952 )?;
3953 let page: V2ManifestPage = serde_json::from_value(value)
3954 .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3955 if page.v != 2
3956 || page.commit != pointer.commit_hash
3957 || page.content_root.as_deref() != Some(root)
3958 || page.files.len() > 500
3959 {
3960 return Err(invalid_feed(
3961 "v2 file manifest is not bound to the verified head",
3962 ));
3963 }
3964 for file in page.files {
3965 verify_v2_file_proof(root, &file)?;
3966 if files
3967 .insert(
3968 file.path.clone(),
3969 V2BaselineFile {
3970 sha256: file.sha256,
3971 bytes: file.bytes,
3972 proof: Some(file.proof),
3973 },
3974 )
3975 .is_some()
3976 {
3977 return Err(invalid_feed("v2 file manifest repeats a path"));
3978 }
3979 if files.len() > MAX_PUSH_FILES {
3980 return Err(invalid_feed(
3981 "v2 file manifest exceeds the file-count bound",
3982 ));
3983 }
3984 }
3985 match page.next_cursor {
3986 None => break,
3987 Some(next) if next > after => after = next,
3988 Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3989 }
3990 }
3991 Ok(files)
3992}
3993
3994fn v2_manifest_file(
3999 cfg: &HubConfig,
4000 brain: &str,
4001 pointer: &V2PointerBody,
4002 path: &str,
4003) -> LinkResult<Option<V2BaselineFile>> {
4004 let Some(root) = pointer.content_root.as_deref() else {
4005 return Ok(None);
4006 };
4007 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
4008 path: error.to_string(),
4009 })?;
4010 let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
4011 let response = request_capped(
4012 cfg,
4013 "GET",
4014 &format!(
4015 "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
4016 pointer.commit_hash
4017 ),
4018 None,
4019 Auth::Required,
4020 MAX_FEED_RESPONSE_BYTES,
4021 )?;
4022 if response.status == 404 {
4026 return Ok(None);
4027 }
4028 let value = ensure_ok(response, "v2 exact file proof")?;
4029 let mut page: V2ManifestPage = serde_json::from_value(value)
4030 .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
4031 if page.v != 2
4032 || page.commit != pointer.commit_hash
4033 || page.content_root.as_deref() != Some(root)
4034 || page.next_cursor.is_some()
4035 || page.files.len() != 1
4036 || page.files[0].path != path
4037 {
4038 return Err(invalid_feed(
4039 "v2 exact file proof is not bound to the requested signed path",
4040 ));
4041 }
4042 let file = page.files.pop().expect("exactly one file was checked");
4043 verify_v2_file_proof(root, &file)?;
4044 Ok(Some(V2BaselineFile {
4045 sha256: file.sha256,
4046 bytes: file.bytes,
4047 proof: Some(file.proof),
4048 }))
4049}
4050
4051fn v2_manifest_file_by_id(
4056 cfg: &HubConfig,
4057 brain: &str,
4058 pointer: &V2PointerBody,
4059 id: &str,
4060) -> LinkResult<(String, V2BaselineFile)> {
4061 let root = pointer
4062 .content_root
4063 .as_deref()
4064 .ok_or_else(|| LinkError::Http {
4065 what: "resolve",
4066 status: 404,
4067 message: "record not found".to_string(),
4068 code: Some("NOT_FOUND".to_string()),
4069 details: None,
4070 })?;
4071 if !crate::ulid::is_ulid(id) {
4072 return Err(LinkError::BadAddress {
4073 given: id.to_string(),
4074 reason: BAD_TARGET_REASON.to_string(),
4075 });
4076 }
4077 let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
4078 let value = ensure_ok(
4079 request_capped(
4080 cfg,
4081 "GET",
4082 &format!(
4083 "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
4084 pointer.commit_hash
4085 ),
4086 None,
4087 Auth::Required,
4088 MAX_FEED_RESPONSE_BYTES,
4089 )?,
4090 "v2 exact id proof",
4091 )?;
4092 let mut page: V2ManifestPage = serde_json::from_value(value)
4093 .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
4094 if page.v != 2
4095 || page.commit != pointer.commit_hash
4096 || page.content_root.as_deref() != Some(root)
4097 || page.next_cursor.is_some()
4098 || page.files.len() != 1
4099 {
4100 return Err(invalid_feed(
4101 "v2 exact id proof is not bound to one signed path",
4102 ));
4103 }
4104 let file = page.files.pop().expect("exactly one file was checked");
4105 if !safe_store_rel_path(&file.path)
4106 || !file.path.ends_with(".md")
4107 || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
4108 {
4109 return Err(invalid_feed("v2 exact id proof has an invalid record path"));
4110 }
4111 verify_v2_file_proof(root, &file)?;
4112 Ok((
4113 file.path,
4114 V2BaselineFile {
4115 sha256: file.sha256,
4116 bytes: file.bytes,
4117 proof: Some(file.proof),
4118 },
4119 ))
4120}
4121
4122fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
4123 crate::linkmd_v2::normalize_path(&item.path)
4124 .map_err(|error| invalid_feed(error.to_string()))?;
4125 if !is_sha256(&item.blob_sha256)
4126 || !is_sha256(&item.leaf_hash)
4127 || item.bytes > MAX_ASSET_BYTES
4128 || item.wrappers.is_empty()
4129 || !matches!(item.disposition.as_str(), "hosted" | "withheld")
4130 || item
4131 .wrappers
4132 .iter()
4133 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4134 {
4135 return Err(invalid_feed("v2 asset manifest item is invalid"));
4136 }
4137 let leaf = json!({
4138 "blob_sha256": item.blob_sha256,
4139 "bytes": item.bytes,
4140 "disposition": item.disposition,
4141 "media_type": item.media_type,
4142 "path": item.path,
4143 "required": item.required,
4144 "v": 2,
4145 "wrappers": item.wrappers,
4146 });
4147 if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
4148 .map_err(|error| invalid_feed(error.to_string()))?
4149 != item.leaf_hash
4150 || !crate::linkmd_v2::verify_proof_with_domain(
4151 root,
4152 &item.path,
4153 &item.proof,
4154 crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
4155 )
4156 .map_err(|error| invalid_feed(error.to_string()))?
4157 {
4158 return Err(invalid_feed("v2 asset inclusion proof failed"));
4159 }
4160 match &item.proof {
4161 crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
4162 if entry.name == item.path
4163 && entry.kind == crate::linkmd_v2::EntryKind::Blob
4164 && entry.child_hash == item.leaf_hash
4165 && entry.bytes == Some(item.bytes) =>
4166 {
4167 Ok(())
4168 }
4169 _ => Err(invalid_feed(
4170 "v2 asset proof leaf differs from its manifest",
4171 )),
4172 }
4173}
4174
4175fn v2_asset_manifest(
4176 cfg: &HubConfig,
4177 brain: &str,
4178 pointer: Option<&V2PointerBody>,
4179) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
4180 let Some(pointer) = pointer else {
4181 return Ok(std::collections::BTreeMap::new());
4182 };
4183 let Some(root) = pointer.asset_root.as_deref() else {
4184 return Ok(std::collections::BTreeMap::new());
4185 };
4186 let mut assets = std::collections::BTreeMap::new();
4187 let mut after = String::new();
4188 loop {
4189 let encoded_after: String =
4190 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4191 let path = format!(
4192 "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4193 pointer.commit_hash
4194 );
4195 let value = ensure_ok(
4196 request_capped(
4197 cfg,
4198 "GET",
4199 &path,
4200 None,
4201 Auth::Required,
4202 MAX_FEED_RESPONSE_BYTES,
4203 )?,
4204 "v2 asset manifest",
4205 )?;
4206 let page: V2AssetManifestPage = serde_json::from_value(value)
4207 .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4208 if page.v != 2
4209 || page.commit != pointer.commit_hash
4210 || page.asset_root.as_deref() != Some(root)
4211 || page.assets.len() > 500
4212 {
4213 return Err(invalid_feed(
4214 "v2 asset manifest is not bound to the verified head",
4215 ));
4216 }
4217 for item in page.assets {
4218 verify_v2_asset_proof(root, &item)?;
4219 let path = item.path.clone();
4220 if assets
4221 .insert(
4222 path,
4223 V2BaselineAsset {
4224 blob_sha256: item.blob_sha256,
4225 bytes: item.bytes,
4226 media_type: item.media_type,
4227 wrappers: item.wrappers,
4228 required: item.required,
4229 disposition: item.disposition,
4230 leaf_hash: item.leaf_hash,
4231 },
4232 )
4233 .is_some()
4234 {
4235 return Err(invalid_feed("v2 asset manifest repeats a path"));
4236 }
4237 if assets.len() > MAX_PUSH_FILES {
4238 return Err(invalid_feed(
4239 "v2 asset manifest exceeds the item-count bound",
4240 ));
4241 }
4242 }
4243 match page.next_cursor {
4244 None => break,
4245 Some(next) if next > after => after = next,
4246 Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4247 }
4248 }
4249 Ok(assets)
4250}
4251
4252fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4253 crate::AssetRecord {
4254 path: path.to_string(),
4255 sha256: asset.blob_sha256.clone(),
4256 bytes: asset.bytes,
4257 media_type: asset.media_type.clone(),
4258 wrappers: asset.wrappers.clone(),
4259 required: asset.required,
4260 }
4261}
4262
4263fn v2_asset_resumes_hosting(
4264 remote: Option<&V2BaselineAsset>,
4265 path: &str,
4266 record: &crate::AssetRecord,
4267 disposition: &str,
4268) -> bool {
4269 remote.is_some_and(|asset| {
4270 asset.disposition == "withheld"
4271 && disposition == "hosted"
4272 && v2_asset_record(asset, path) == *record
4273 })
4274}
4275
4276fn v2_asset_inherits_withheld_absence(
4277 base: Option<&V2BaselineAsset>,
4278 base_record: Option<&crate::AssetRecord>,
4279 local_record: Option<&crate::AssetRecord>,
4280 raw_present: bool,
4281) -> bool {
4282 !raw_present
4283 && base.is_some_and(|asset| asset.disposition == "withheld")
4284 && local_record == base_record
4285}
4286
4287fn v2_asset_record_manifest_bytes(
4288 assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4289) -> LinkResult<Vec<u8>> {
4290 let mut bytes = Vec::new();
4291 for (path, asset) in assets {
4292 if asset.path != *path {
4293 return Err(invalid_feed(
4294 "local asset manifest key differs from its record path",
4295 ));
4296 }
4297 serde_json::to_writer(&mut bytes, asset)
4298 .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4299 bytes.push(b'\n');
4300 }
4301 Ok(bytes)
4302}
4303
4304fn v2_local_asset_records(
4305 store: &Store,
4306) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4307 let assets = crate::assets::read_manifest(store)
4308 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?;
4309 if assets.iter().any(|asset| asset.bytes > MAX_ASSET_BYTES) {
4310 return Err(LinkError::InvalidPack {
4311 message: "local asset exceeds the link.md v2 per-blob limit".to_string(),
4312 });
4313 }
4314 Ok(assets
4315 .into_iter()
4316 .map(|asset| (asset.path.clone(), asset))
4317 .collect())
4318}
4319
4320fn v2_asset_records_match_remote(
4321 local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4322 remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4323) -> bool {
4324 local.len() == remote.len()
4325 && remote
4326 .iter()
4327 .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4328}
4329
4330#[derive(Debug, Clone, PartialEq, Eq)]
4331struct V2PulledMerge<T> {
4332 records: std::collections::BTreeMap<String, T>,
4333 accept_remote: std::collections::BTreeSet<String>,
4334 conflicts: Vec<String>,
4335}
4336
4337fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4343 base: &std::collections::BTreeMap<String, Base>,
4344 remote: &std::collections::BTreeMap<String, Remote>,
4345 local: &std::collections::BTreeMap<String, Record>,
4346 base_record: BaseRecord,
4347 remote_record: RemoteRecord,
4348 keep_local: KeepLocal,
4349) -> V2PulledMerge<Record>
4350where
4351 Record: Clone + Eq,
4352 BaseRecord: Fn(&Base, &str) -> Record,
4353 RemoteRecord: Fn(&Remote, &str) -> Record,
4354 KeepLocal: Fn(&str) -> bool,
4355{
4356 let paths = base
4357 .keys()
4358 .chain(remote.keys())
4359 .chain(local.keys())
4360 .cloned()
4361 .collect::<std::collections::BTreeSet<_>>();
4362 let mut records = local.clone();
4363 let mut accept_remote = std::collections::BTreeSet::new();
4364 let mut conflicts = Vec::new();
4365 for path in paths {
4366 if keep_local(&path) {
4367 continue;
4368 }
4369 let base_value = base.get(&path).map(|value| base_record(value, &path));
4370 let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4371 let local_value = local.get(&path).cloned();
4372 if local_value != base_value && remote_value != base_value && local_value != remote_value {
4373 conflicts.push(path);
4374 continue;
4375 }
4376 if local_value == base_value || local_value == remote_value {
4377 accept_remote.insert(path.clone());
4378 match remote_value {
4379 Some(value) => {
4380 records.insert(path, value);
4381 }
4382 None => {
4383 records.remove(&path);
4384 }
4385 }
4386 }
4387 }
4388 V2PulledMerge {
4389 records,
4390 accept_remote,
4391 conflicts,
4392 }
4393}
4394
4395fn sign_verified_v2_candidate(
4396 cfg: &HubConfig,
4397 head: &V2VerifiedHead,
4398 expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4399 expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4400 mutation_id: &str,
4401 request_body: &Value,
4402 challenge_value: &Value,
4403) -> LinkResult<(String, String, String)> {
4404 if head.view_kind != "full" {
4405 return Err(invalid_feed(
4406 "a scoped self-custody writer must use the proposal workflow",
4407 ));
4408 }
4409 if head.identity.custody != "self" {
4410 return Err(invalid_feed(
4411 "a hub-custodied brain unexpectedly requested an external signature",
4412 ));
4413 }
4414 let key = cfg
4415 .brain_key
4416 .as_ref()
4417 .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4418 if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4419 || key.public_key_spki != head.identity.public_key_spki
4420 {
4421 return Err(bad_agent_key(
4422 "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4423 ));
4424 }
4425 let challenge_id = challenge_value
4426 .get("id")
4427 .and_then(Value::as_str)
4428 .filter(|id| crate::ulid::is_ulid(id))
4429 .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4430 let expected_endpoint = format!(
4431 "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4432 head.brain_id
4433 );
4434 if challenge_value
4435 .get("candidate_endpoint")
4436 .and_then(Value::as_str)
4437 != Some(expected_endpoint.as_str())
4438 {
4439 return Err(invalid_feed(
4440 "self-custody challenge candidate endpoint is not origin-bound",
4441 ));
4442 }
4443
4444 let mut files = std::collections::BTreeMap::new();
4445 let mut after = String::new();
4446 type CandidateCoordinate = (
4447 String,
4448 String,
4449 String,
4450 String,
4451 Option<String>,
4452 Option<String>,
4453 u64,
4454 Option<String>,
4455 );
4456 let mut pinned: Option<CandidateCoordinate> = None;
4457 loop {
4458 let encoded_after: String =
4459 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4460 let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4461 let value = ensure_ok(
4462 request_capped(
4463 cfg,
4464 "GET",
4465 &path,
4466 None,
4467 Auth::Required,
4468 MAX_FEED_RESPONSE_BYTES,
4469 )?,
4470 "v2 self-custody candidate",
4471 )?;
4472 let page: V2SigningCandidatePage = serde_json::from_value(value)
4473 .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4474 if page.v != 2
4475 || page.challenge_id != challenge_id
4476 || page.mutation_id != mutation_id
4477 || page.candidate.seq != page.parent.seq + 1
4478 || page.files.len() > 500
4479 || page.expires_at.is_empty()
4480 {
4481 return Err(invalid_feed(
4482 "self-custody candidate is not bound to this mutation",
4483 ));
4484 }
4485 let coordinate = (
4486 page.request_hash.clone(),
4487 page.candidate.signing_bytes_base64.clone(),
4488 page.candidate.changes_base64.clone(),
4489 page.candidate.actor_claim_base64.clone(),
4490 page.candidate.content_root.clone(),
4491 page.candidate.asset_root.clone(),
4492 page.parent.seq,
4493 page.parent.commit_hash.clone(),
4494 );
4495 if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4496 return Err(invalid_feed(
4497 "self-custody candidate changed between manifest pages",
4498 ));
4499 }
4500 pinned = Some(coordinate);
4501 let root = page
4502 .candidate
4503 .content_root
4504 .as_deref()
4505 .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4506 for file in page.files {
4507 verify_v2_file_proof(root, &file)?;
4508 if files
4509 .insert(
4510 file.path.clone(),
4511 V2BaselineFile {
4512 sha256: file.sha256,
4513 bytes: file.bytes,
4514 proof: Some(file.proof),
4515 },
4516 )
4517 .is_some()
4518 {
4519 return Err(invalid_feed(
4520 "self-custody candidate repeats a manifest path",
4521 ));
4522 }
4523 if files.len() > MAX_PUSH_FILES {
4524 return Err(invalid_feed(
4525 "self-custody candidate exceeds the file-count bound",
4526 ));
4527 }
4528 }
4529 match page.next_cursor {
4530 None => break,
4531 Some(next) if next > after => after = next,
4532 Some(_) => {
4533 return Err(invalid_feed(
4534 "self-custody candidate cursor did not advance",
4535 ))
4536 }
4537 }
4538 }
4539 if files.len() != expected.len()
4540 || files.iter().any(|(path, file)| {
4541 expected.get(path).is_none_or(|expected| {
4542 expected.sha256 != file.sha256 || expected.bytes != file.bytes
4543 })
4544 })
4545 {
4546 return Err(invalid_feed(
4547 "self-custody candidate contains an unexpected file mutation",
4548 ));
4549 }
4550 let mut assets = std::collections::BTreeMap::new();
4551 after.clear();
4552 loop {
4553 let encoded_after: String =
4554 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4555 let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4556 let value = ensure_ok(
4557 request_capped(
4558 cfg,
4559 "GET",
4560 &path,
4561 None,
4562 Auth::Required,
4563 MAX_FEED_RESPONSE_BYTES,
4564 )?,
4565 "v2 self-custody asset candidate",
4566 )?;
4567 let page: V2SigningCandidatePage = serde_json::from_value(value)
4568 .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4569 let coordinate = (
4570 page.request_hash.clone(),
4571 page.candidate.signing_bytes_base64.clone(),
4572 page.candidate.changes_base64.clone(),
4573 page.candidate.actor_claim_base64.clone(),
4574 page.candidate.content_root.clone(),
4575 page.candidate.asset_root.clone(),
4576 page.parent.seq,
4577 page.parent.commit_hash.clone(),
4578 );
4579 if page.v != 2
4580 || page.challenge_id != challenge_id
4581 || page.mutation_id != mutation_id
4582 || page.assets.len() > 500
4583 || pinned.as_ref() != Some(&coordinate)
4584 {
4585 return Err(invalid_feed(
4586 "self-custody asset candidate changed or is not bound",
4587 ));
4588 }
4589 let root = page.candidate.asset_root.as_deref();
4590 if !page.assets.is_empty() && root.is_none() {
4591 return Err(invalid_feed("asset candidate has no asset root"));
4592 }
4593 for item in page.assets {
4594 verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4595 if assets
4596 .insert(
4597 item.path.clone(),
4598 V2BaselineAsset {
4599 blob_sha256: item.blob_sha256,
4600 bytes: item.bytes,
4601 media_type: item.media_type,
4602 wrappers: item.wrappers,
4603 required: item.required,
4604 disposition: item.disposition,
4605 leaf_hash: item.leaf_hash,
4606 },
4607 )
4608 .is_some()
4609 {
4610 return Err(invalid_feed("self-custody candidate repeats an asset"));
4611 }
4612 }
4613 match page.next_cursor {
4614 None => break,
4615 Some(next) if next > after => after = next,
4616 Some(_) => {
4617 return Err(invalid_feed(
4618 "self-custody asset candidate cursor did not advance",
4619 ))
4620 }
4621 }
4622 }
4623 if assets.len() != expected_assets.len()
4624 || assets.iter().any(|(path, asset)| {
4625 expected_assets.get(path).is_none_or(|expected| {
4626 asset.blob_sha256 != expected.blob_sha256
4627 || asset.bytes != expected.bytes
4628 || asset.media_type != expected.media_type
4629 || asset.wrappers != expected.wrappers
4630 || asset.required != expected.required
4631 || asset.disposition != expected.disposition
4632 })
4633 })
4634 {
4635 return Err(invalid_feed(
4636 "self-custody candidate contains an unexpected asset mutation",
4637 ));
4638 }
4639 let Some((
4640 request_hash,
4641 signing_b64,
4642 changes_b64,
4643 actor_b64,
4644 root,
4645 asset_root,
4646 parent_seq,
4647 parent,
4648 )) = pinned
4649 else {
4650 return Err(invalid_feed("self-custody candidate has no manifest"));
4651 };
4652 let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4653 let current_commit = head
4654 .pointer
4655 .as_ref()
4656 .map(|pointer| pointer.commit_hash.clone());
4657 if parent_seq != current_seq || parent != current_commit {
4658 return Err(LinkError::RemoteAdvancedDuringSync);
4659 }
4660 let changes = STANDARD
4661 .decode(changes_b64)
4662 .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4663 let mut expected_changes = json!({
4664 "mutation_id": mutation_id,
4665 "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4666 "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4667 "v": 2,
4668 });
4669 if let Some(withheld_links) = request_body.get("withheld_links") {
4670 expected_changes["withheld_links"] = withheld_links.clone();
4671 }
4672 if let Some(checkout_id) = request_body.get("checkout_id") {
4673 expected_changes["checkout_id"] = checkout_id.clone();
4674 }
4675 let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4676 .map_err(|error| invalid_feed(error.to_string()))?;
4677 if changes != expected_changes_bytes {
4678 return Err(invalid_feed(
4679 "self-custody changeset differs from the requested mutation",
4680 ));
4681 }
4682 let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4683 .map_err(|error| invalid_feed(error.to_string()))?;
4684 let request_value = json!({
4685 "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4686 "brain": head.brain_id,
4687 "changes_sha256": changes_hash,
4688 "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4689 "v": 2,
4690 "v1_bridge": Value::Null,
4691 });
4692 let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4693 .map_err(|error| invalid_feed(error.to_string()))?;
4694 if request_hash != expected_request_hash {
4695 return Err(invalid_feed(
4696 "self-custody request hash differs from the requested mutation",
4697 ));
4698 }
4699 let actor = STANDARD
4700 .decode(actor_b64)
4701 .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4702 let actor_value: Value = serde_json::from_slice(&actor)
4703 .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4704 if crate::linkmd_v2::canonical_bytes(&actor_value)
4705 .map_err(|error| invalid_feed(error.to_string()))?
4706 != actor
4707 {
4708 return Err(invalid_feed("self-custody actor claim is not canonical"));
4709 }
4710 let actor_object = actor_value
4711 .as_object()
4712 .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4713 let actor_claim = actor_object
4714 .get("claim")
4715 .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4716 let actor_public_key = actor_object
4717 .get("public_key")
4718 .and_then(Value::as_str)
4719 .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4720 let actor_fingerprint = actor_object
4721 .get("fingerprint")
4722 .and_then(Value::as_str)
4723 .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4724 let actor_signature = actor_object
4725 .get("sig")
4726 .and_then(Value::as_str)
4727 .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4728 let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4729 .map_err(|error| invalid_feed(error.to_string()))?;
4730 let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4731 let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4732 let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4733 let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4734 let impact = actor_claim
4735 .get("result")
4736 .and_then(|result| result.get("impact"))
4737 .and_then(Value::as_object);
4738 let impact_fields = [
4739 "creates",
4740 "updates",
4741 "deletes",
4742 "withdrawals",
4743 "renames",
4744 "restores",
4745 "asset_changes",
4746 "public_expansions",
4747 "executable_activations",
4748 ];
4749 let impact_is_valid = impact.is_some_and(|impact| {
4750 impact.len() == impact_fields.len() + 1
4751 && impact.get("v").and_then(Value::as_u64) == Some(1)
4752 && impact_fields
4753 .iter()
4754 .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4755 });
4756 if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4757 || head
4758 .trust
4759 .hub_signer
4760 .as_ref()
4761 .is_some_and(|known| known != &expected_actor_signer)
4762 || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4763 || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4764 || actor_claim
4765 .get("candidate")
4766 .and_then(|candidate| candidate.get("changes_sha256"))
4767 .and_then(Value::as_str)
4768 != Some(changes_hash.as_str())
4769 || actor_claim
4770 .get("candidate")
4771 .and_then(|candidate| candidate.get("state_root"))
4772 != Some(&expected_actor_root)
4773 || actor_claim
4774 .get("candidate")
4775 .and_then(|candidate| candidate.get("asset_root"))
4776 != Some(&expected_actor_asset_root)
4777 || actor_claim
4778 .get("candidate")
4779 .and_then(|candidate| candidate.get("control_revision"))
4780 .and_then(Value::as_str)
4781 != Some(head.control_revision.as_str())
4782 || !impact_is_valid
4783 {
4784 return Err(invalid_feed(
4785 "self-custody actor claim does not bind the verified authority",
4786 ));
4787 }
4788 let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4789 .map_err(|error| invalid_feed(error.to_string()))?;
4790 let signing = STANDARD
4791 .decode(signing_b64)
4792 .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4793 let signing_value: Value = serde_json::from_slice(&signing)
4794 .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4795 if crate::linkmd_v2::canonical_bytes(&signing_value)
4796 .map_err(|error| invalid_feed(error.to_string()))?
4797 != signing
4798 {
4799 return Err(invalid_feed("self-custody signing bytes are not canonical"));
4800 }
4801 let pointer = head.pointer.as_ref();
4802 let expected_materializer = pointer
4803 .map(|value| value.materializer.as_str())
4804 .unwrap_or("dbmd-projection-v1");
4805 let expected_parent_commit = request_body
4806 .get("base")
4807 .and_then(|base| base.get("commit_hash"))
4808 .cloned()
4809 .unwrap_or(Value::Null);
4810 let expected_parent_root = request_body
4811 .get("base")
4812 .and_then(|base| base.get("content_root"))
4813 .cloned()
4814 .unwrap_or(Value::Null);
4815 let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4816 let expected_parent_asset_root = request_body
4817 .get("base")
4818 .and_then(|base| base.get("asset_root"))
4819 .cloned()
4820 .unwrap_or(Value::Null);
4821 let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4822 let expected_prev_entry = pointer
4823 .map(|value| Value::String(value.feed_hash.clone()))
4824 .unwrap_or(Value::Null);
4825 let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4826 .map_err(|_| invalid_feed("brain identity history is too large"))?
4827 + 1;
4828 if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4829 || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4830 || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4831 || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4832 || signing_value.get("public_key").and_then(Value::as_str)
4833 != Some(key.public_key_spki.as_str())
4834 || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4835 || signing_value.get("parent_root") != Some(&expected_parent_root)
4836 || signing_value.get("state_root") != Some(&expected_state_root)
4837 || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4838 || signing_value.get("asset_root") != Some(&expected_asset_root)
4839 || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4840 || signing_value.get("changes_sha256").and_then(Value::as_str)
4841 != Some(changes_hash.as_str())
4842 || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4843 || signing_value
4844 .get("control_revision")
4845 .and_then(Value::as_str)
4846 != Some(head.control_revision.as_str())
4847 || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4848 || signing_value.get("v1_bridge") != Some(&Value::Null)
4849 || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4850 {
4851 return Err(invalid_feed(
4852 "self-custody signing bytes do not bind the verified candidate",
4853 ));
4854 }
4855 let pair = agent_keypair(&key.pkcs8)?;
4856 let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4857 Ok((challenge_id.to_string(), signature, expected_actor_signer))
4858}
4859
4860fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4861 let origin = normalized_origin(&cfg.hub)?;
4862 let absolute = if checkout.is_absolute() {
4863 checkout.to_path_buf()
4864 } else {
4865 std::env::current_dir()?.join(checkout)
4866 };
4867 Ok(format!(
4868 "sync-{}.json",
4869 content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4870 ))
4871}
4872
4873fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4874 if let Some(value) = existing {
4875 if !is_sha256(value) {
4876 return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4877 }
4878 return Ok(value.to_string());
4879 }
4880 use ring::rand::SecureRandom as _;
4881 let mut random = [0_u8; 32];
4882 ring::rand::SystemRandom::new()
4883 .fill(&mut random)
4884 .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4885 Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4886}
4887
4888#[cfg(any(unix, windows))]
4889fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4890 let directory = open_trust_dir(cfg)?;
4891 let origin = normalized_origin(&cfg.hub)?;
4892 let name = format!(
4893 "operation-{}.lock",
4894 content_sha256(format!("{origin}\0{brain}").as_bytes())
4895 );
4896 lock_trust_name(&directory, &name)
4897}
4898
4899#[cfg(not(any(unix, windows)))]
4900fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4901 Err(LinkError::UnsupportedPlatform {
4902 operation: "serialized link.md v2 sync",
4903 })
4904}
4905
4906fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4907 left.brain_id == right.brain_id
4908 && left.view_kind == right.view_kind
4909 && left.view_revision == right.view_revision
4910 && left.control_revision == right.control_revision
4911 && match (&left.pointer, &right.pointer) {
4912 (None, None) => true,
4913 (Some(left), Some(right)) => {
4914 left.seq == right.seq
4915 && left.commit_hash == right.commit_hash
4916 && left.content_root == right.content_root
4917 && left.asset_root == right.asset_root
4918 && left.feed_hash == right.feed_hash
4919 }
4920 _ => false,
4921 }
4922}
4923
4924fn v2_baseline_matches_head(head: &V2VerifiedHead, baseline: &V2SyncBaseline) -> bool {
4930 let pointer = head.pointer.as_ref();
4931 baseline.head_seq == Some(pointer.map_or(0, |value| value.seq))
4932 && baseline.commit_hash.as_deref() == pointer.map(|value| value.commit_hash.as_str())
4933 && baseline.content_root.as_deref()
4934 == pointer.and_then(|value| value.content_root.as_deref())
4935 && baseline.asset_root.as_deref() == pointer.and_then(|value| value.asset_root.as_deref())
4936 && baseline.view_kind.as_deref() == Some(head.view_kind.as_str())
4937 && baseline.view_revision.as_deref() == Some(head.view_revision.as_str())
4938 && baseline.control_revision.as_deref() == Some(head.control_revision.as_str())
4939}
4940
4941fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4942 format!(
4943 "---\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"
4944 )
4945 .into_bytes()
4946}
4947
4948fn scoped_projection_sha256(brain: &str) -> String {
4949 content_sha256(&scoped_projection_bytes(brain))
4950}
4951
4952#[derive(Deserialize)]
4953struct LocalScopedViewMarker {
4954 v: u8,
4955 kind: String,
4956 authoritative: bool,
4957 brain: String,
4958 projection_sha256: String,
4959}
4960
4961pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4965 let marker = store
4966 .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4967 .ok()
4968 .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4969 let Some(marker) = marker else {
4970 return false;
4971 };
4972 if marker.v != 1
4973 || marker.kind != "link.md-scoped-view"
4974 || marker.authoritative
4975 || !crate::ulid::is_ulid(&marker.brain)
4976 || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4977 {
4978 return false;
4979 }
4980 store
4981 .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4982 .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4983}
4984
4985fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4986 let mut bytes = serde_json::to_vec_pretty(&json!({
4987 "v": 1,
4988 "kind": "link.md-scoped-view",
4989 "authoritative": false,
4990 "brain": head.brain_id,
4991 "view_revision": head.view_revision,
4992 "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4993 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4994 "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4995 "visible_files": files,
4996 "projection_sha256": scoped_projection_sha256(&head.brain_id),
4997 }))
4998 .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4999 bytes.push(b'\n');
5000 Ok(bytes)
5001}
5002
5003fn refresh_scoped_view_marker(
5004 store: &Store,
5005 head: &V2VerifiedHead,
5006 files: usize,
5007) -> LinkResult<()> {
5008 if head.view_kind == "scoped" {
5009 store.write_atomic(
5010 Path::new(".dbmd/view.json"),
5011 &scoped_view_metadata(head, files)?,
5012 )?;
5013 }
5014 Ok(())
5015}
5016
5017fn ensure_v2_view_compatible(
5018 head: &V2VerifiedHead,
5019 baseline: Option<&V2SyncBaseline>,
5020) -> LinkResult<()> {
5021 let Some(baseline) = baseline else {
5022 return Ok(());
5023 };
5024 match (
5025 baseline.view_kind.as_deref(),
5026 baseline.view_revision.as_deref(),
5027 ) {
5028 (None, None) if head.view_kind == "full" => Ok(()),
5029 (Some(kind), Some(revision))
5030 if kind == head.view_kind && revision == head.view_revision =>
5031 {
5032 Ok(())
5033 }
5034 _ => Err(LinkError::ScopedViewChanged),
5035 }
5036}
5037
5038fn ensure_established_v2_checkout_opened(
5039 head: &V2VerifiedHead,
5040 baseline: Option<&V2SyncBaseline>,
5041 opened: bool,
5042) -> LinkResult<()> {
5043 if baseline.is_none() || opened {
5044 return Ok(());
5045 }
5046 if head.view_kind == "scoped" {
5047 return Err(LinkError::ScopedProjectionModified);
5048 }
5049 Err(LinkError::InvalidPack {
5050 message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
5051 })
5052}
5053
5054fn remove_scoped_projection(
5055 head: &V2VerifiedHead,
5056 baseline: Option<&V2SyncBaseline>,
5057 view: &mut V2LocalView,
5058) -> LinkResult<()> {
5059 if head.view_kind != "scoped" {
5060 return Ok(());
5061 }
5062 let expected = scoped_projection_sha256(&head.brain_id);
5063 if baseline
5064 .and_then(|state| state.projection_sha256.as_deref())
5065 .is_some_and(|pinned| pinned != expected)
5066 {
5067 return Err(LinkError::ScopedViewChanged);
5068 }
5069 if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
5070 return Err(LinkError::ScopedProjectionModified);
5071 }
5072 view.riding.remove("DB.md");
5073 view.scan_cache.remove("DB.md");
5074 view.eligibility.remove("DB.md");
5075 Ok(())
5076}
5077
5078fn local_view_for_v2_push(
5079 store: &Store,
5080 head: &V2VerifiedHead,
5081 baseline: Option<&V2SyncBaseline>,
5082 carried: Option<V2LocalView>,
5083) -> LinkResult<V2LocalView> {
5084 match carried {
5085 Some(view) => Ok(view),
5090 None => {
5091 let hint = baseline.and_then(|state| {
5092 state
5093 .local_policy_digest
5094 .as_deref()
5095 .map(|digest| (digest, &state.scan_cache))
5096 });
5097 let mut view = v2_local_files_cached(store, hint)?;
5098 remove_scoped_projection(head, baseline, &mut view)?;
5099 Ok(view)
5100 }
5101 }
5102}
5103
5104fn files_for_v2_view(
5105 head: &V2VerifiedHead,
5106 mut files: std::collections::BTreeMap<String, V2BaselineFile>,
5107) -> std::collections::BTreeMap<String, V2BaselineFile> {
5108 if head.view_kind == "scoped" {
5109 files.remove("DB.md");
5113 }
5114 files
5115}
5116
5117fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
5118 let baseline: V2SyncBaseline =
5119 serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
5120 if baseline.v != 2
5121 || baseline.origin != normalized_origin(&cfg.hub)?
5122 || baseline.brain != brain
5123 || baseline
5124 .commit_hash
5125 .as_deref()
5126 .is_some_and(|hash| !is_sha256(hash))
5127 || baseline
5128 .content_root
5129 .as_deref()
5130 .is_some_and(|hash| !is_sha256(hash))
5131 || baseline
5132 .asset_root
5133 .as_deref()
5134 .is_some_and(|hash| !is_sha256(hash))
5135 || baseline
5136 .local_policy_digest
5137 .as_deref()
5138 .is_some_and(|hash| !is_sha256(hash))
5139 || baseline
5140 .view_kind
5141 .as_deref()
5142 .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
5143 || baseline
5144 .view_revision
5145 .as_deref()
5146 .is_some_and(|hash| !is_sha256(hash))
5147 || baseline
5148 .control_revision
5149 .as_deref()
5150 .is_some_and(|hash| !is_sha256(hash))
5151 || baseline
5152 .projection_sha256
5153 .as_deref()
5154 .is_some_and(|hash| !is_sha256(hash))
5155 || (baseline.view_kind.as_deref() == Some("scoped")
5156 && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
5157 || baseline.files.len() > MAX_PUSH_FILES
5158 || baseline.scan_cache.len() > MAX_PUSH_FILES
5159 || baseline.assets.len() > MAX_PUSH_FILES
5160 || baseline.local_eligibility.len() > MAX_PUSH_FILES
5161 || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
5162 || baseline.files.iter().any(|(path, file)| {
5163 crate::linkmd_v2::normalize_path(path).is_err()
5164 || !is_sha256(&file.sha256)
5165 || file.bytes > MAX_STORE_BYTES
5166 })
5167 || baseline.scan_cache.iter().any(|(path, file)| {
5168 crate::linkmd_v2::normalize_path(path).is_err()
5169 || !file.fingerprint.starts_with("unix-v1:")
5170 || file.fingerprint.len() > 256
5171 || !file.fingerprint.is_ascii()
5172 || !is_sha256(&file.sha256)
5173 || file.bytes > MAX_STORE_BYTES
5174 || file.withheld_targets.len() > MAX_PUSH_FILES
5175 || file.withheld_targets.iter().any(|target| {
5176 target.len() > MAX_STORE_PATH_BYTES
5177 || crate::linkmd_v2::normalize_path(target).is_err()
5178 })
5179 || baseline.files.get(path).is_none_or(|baseline_file| {
5180 baseline_file.sha256 != file.sha256 || baseline_file.bytes != file.bytes
5181 })
5182 })
5183 || baseline.assets.iter().any(|(path, asset)| {
5184 crate::linkmd_v2::normalize_path(path).is_err()
5185 || !is_sha256(&asset.blob_sha256)
5186 || !is_sha256(&asset.leaf_hash)
5187 || asset.bytes > MAX_ASSET_BYTES
5188 || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
5189 || asset.wrappers.is_empty()
5190 || asset
5191 .wrappers
5192 .iter()
5193 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
5194 })
5195 || baseline
5196 .local_eligibility
5197 .keys()
5198 .chain(baseline.remote_copy_remains.keys())
5199 .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
5200 || baseline
5201 .remote_copy_remains
5202 .values()
5203 .any(|hash| !is_sha256(hash))
5204 || baseline
5205 .checkout_id
5206 .as_deref()
5207 .is_some_and(|checkout_id| !is_sha256(checkout_id))
5208 {
5209 return Err(invalid_feed("v2 sync baseline failed validation"));
5210 }
5211 Ok(baseline)
5212}
5213
5214#[cfg(unix)]
5215fn load_v2_baseline_in(
5216 cfg: &HubConfig,
5217 brain: &str,
5218 directory: &TrustDirectory,
5219 name_string: &str,
5220) -> LinkResult<Option<V2SyncBaseline>> {
5221 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5222 let name = c_name(name_string.as_bytes(), name_string)?;
5223 let fd = unsafe {
5224 libc::openat(
5225 directory.as_raw_fd(),
5226 name.as_ptr(),
5227 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5228 )
5229 };
5230 if fd < 0 {
5231 let error = std::io::Error::last_os_error();
5232 return if error.kind() == std::io::ErrorKind::NotFound {
5233 Ok(None)
5234 } else {
5235 Err(LinkError::UnsafePath {
5236 path: name_string.to_string(),
5237 })
5238 };
5239 }
5240 let file = unsafe { std::fs::File::from_raw_fd(fd) };
5241 let mut bytes = Vec::new();
5242 file.take(MAX_V2_BASELINE_BYTES + 1)
5243 .read_to_end(&mut bytes)?;
5244 if bytes.len() as u64 > MAX_V2_BASELINE_BYTES {
5245 return Err(invalid_feed("v2 sync baseline is oversized"));
5246 }
5247 Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
5248}
5249
5250#[cfg(unix)]
5251fn load_v2_baseline(
5252 cfg: &HubConfig,
5253 brain: &str,
5254 checkout: &Path,
5255) -> LinkResult<Option<V2SyncBaseline>> {
5256 let directory = open_trust_dir(cfg)?;
5257 let name = v2_baseline_name(cfg, brain, checkout)?;
5258 let _lock = lock_trust_name(&directory, &name)?;
5259 load_v2_baseline_in(cfg, brain, &directory, &name)
5260}
5261
5262#[cfg(windows)]
5263fn load_v2_baseline_in(
5264 cfg: &HubConfig,
5265 brain: &str,
5266 directory: &TrustDirectory,
5267 name: &str,
5268) -> LinkResult<Option<V2SyncBaseline>> {
5269 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
5270 match reader.read(Path::new(&name), MAX_V2_BASELINE_BYTES) {
5271 Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5272 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5273 Err(_) => Err(LinkError::UnsafePath {
5274 path: name.to_string(),
5275 }),
5276 }
5277}
5278
5279#[cfg(windows)]
5280fn load_v2_baseline(
5281 cfg: &HubConfig,
5282 brain: &str,
5283 checkout: &Path,
5284) -> LinkResult<Option<V2SyncBaseline>> {
5285 let directory = open_trust_dir(cfg)?;
5286 let name = v2_baseline_name(cfg, brain, checkout)?;
5287 let _lock = lock_trust_name(&directory, &name)?;
5288 load_v2_baseline_in(cfg, brain, &directory, &name)
5289}
5290
5291#[cfg(not(any(unix, windows)))]
5292fn load_v2_baseline(
5293 _cfg: &HubConfig,
5294 _brain: &str,
5295 _checkout: &Path,
5296) -> LinkResult<Option<V2SyncBaseline>> {
5297 Err(LinkError::UnsupportedPlatform {
5298 operation: "verified link.md v2 baseline",
5299 })
5300}
5301
5302#[cfg(unix)]
5303fn save_v2_baseline(
5304 cfg: &HubConfig,
5305 brain: &str,
5306 checkout: &Path,
5307 baseline: &V2SyncBaseline,
5308) -> LinkResult<()> {
5309 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5310 let directory = open_trust_dir(cfg)?;
5311 let name_string = v2_baseline_name(cfg, brain, checkout)?;
5312 let _lock = lock_trust_name(&directory, &name_string)?;
5313 let name = c_name(name_string.as_bytes(), &name_string)?;
5314 let mut bytes = serde_json::to_vec(baseline)
5315 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5316 bytes.push(b'\n');
5317 if bytes.len() as u64 > MAX_V2_BASELINE_BYTES {
5318 return Err(invalid_feed("v2 sync baseline is oversized"));
5319 }
5320 let temp_string = format!(
5321 ".{name_string}.tmp.{}-{}",
5322 std::process::id(),
5323 std::time::SystemTime::now()
5324 .duration_since(std::time::UNIX_EPOCH)
5325 .unwrap_or_default()
5326 .as_nanos()
5327 );
5328 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5329 let fd = unsafe {
5330 libc::openat(
5331 directory.as_raw_fd(),
5332 temp.as_ptr(),
5333 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5334 0o600,
5335 )
5336 };
5337 if fd < 0 {
5338 return Err(std::io::Error::last_os_error().into());
5339 }
5340 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5341 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5342 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5343 return Err(error.into());
5344 }
5345 drop(file);
5346 if unsafe {
5347 libc::renameat(
5348 directory.as_raw_fd(),
5349 temp.as_ptr(),
5350 directory.as_raw_fd(),
5351 name.as_ptr(),
5352 )
5353 } != 0
5354 {
5355 let error = std::io::Error::last_os_error();
5356 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5357 return Err(error.into());
5358 }
5359 directory.sync_all()?;
5360 Ok(())
5361}
5362
5363#[cfg(windows)]
5364fn save_v2_baseline(
5365 cfg: &HubConfig,
5366 brain: &str,
5367 checkout: &Path,
5368 baseline: &V2SyncBaseline,
5369) -> LinkResult<()> {
5370 let directory = open_trust_dir(cfg)?;
5371 let name = v2_baseline_name(cfg, brain, checkout)?;
5372 let _lock = lock_trust_name(&directory, &name)?;
5373 let mut bytes = serde_json::to_vec(baseline)
5374 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5375 bytes.push(b'\n');
5376 if bytes.len() as u64 > MAX_V2_BASELINE_BYTES {
5377 return Err(invalid_feed("v2 sync baseline is oversized"));
5378 }
5379 crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5380 Ok(())
5381}
5382
5383#[cfg(not(any(unix, windows)))]
5384fn save_v2_baseline(
5385 _cfg: &HubConfig,
5386 _brain: &str,
5387 _checkout: &Path,
5388 _baseline: &V2SyncBaseline,
5389) -> LinkResult<()> {
5390 Err(LinkError::UnsupportedPlatform {
5391 operation: "verified link.md v2 baseline",
5392 })
5393}
5394
5395fn v2_baseline_from_head(
5396 cfg: &HubConfig,
5397 head: &V2VerifiedHead,
5398 files: std::collections::BTreeMap<String, V2BaselineFile>,
5399 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5400 local: Option<&V2LocalView>,
5401 checkout_id: Option<&str>,
5402) -> LinkResult<V2SyncBaseline> {
5403 let mut local_eligibility = local
5404 .map(|view| view.eligibility.clone())
5405 .unwrap_or_default();
5406 if let Some(view) = local {
5407 for path in files.keys() {
5408 local_eligibility
5409 .entry(path.clone())
5410 .or_insert_with(|| !view.policy.keeps_home(path));
5411 }
5412 }
5413 let remote_copy_remains = local_eligibility
5414 .iter()
5415 .filter(|(_, riding)| !**riding)
5416 .filter_map(|(path, _)| {
5417 files
5418 .get(path)
5419 .map(|file| (path.clone(), file.sha256.clone()))
5420 })
5421 .collect();
5422 let scan_cache = local
5427 .map(|view| {
5428 view.scan_cache
5429 .iter()
5430 .filter(|(path, cached)| {
5431 files.get(*path).is_some_and(|remote| {
5432 remote.sha256 == cached.sha256 && remote.bytes == cached.bytes
5433 })
5434 })
5435 .map(|(path, cached)| (path.clone(), cached.clone()))
5436 .collect()
5437 })
5438 .unwrap_or_default();
5439 Ok(V2SyncBaseline {
5440 v: 2,
5441 origin: normalized_origin(&cfg.hub)?,
5442 brain: head.brain_id.clone(),
5443 checkout_id: Some(v2_checkout_id(checkout_id)?),
5444 head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5445 commit_hash: head
5446 .pointer
5447 .as_ref()
5448 .map(|pointer| pointer.commit_hash.clone()),
5449 content_root: head
5450 .pointer
5451 .as_ref()
5452 .and_then(|pointer| pointer.content_root.clone()),
5453 asset_root: head
5454 .pointer
5455 .as_ref()
5456 .and_then(|pointer| pointer.asset_root.clone()),
5457 assets,
5458 view_kind: Some(head.view_kind.clone()),
5459 view_revision: Some(head.view_revision.clone()),
5460 control_revision: Some(head.control_revision.clone()),
5461 projection_sha256: (head.view_kind == "scoped")
5462 .then(|| scoped_projection_sha256(&head.brain_id)),
5463 files,
5464 scan_cache,
5465 local_policy_digest: local.map(|view| view.policy.digest.clone()),
5466 local_eligibility,
5467 remote_copy_remains,
5468 })
5469}
5470
5471#[cfg(unix)]
5472fn v2_scan_fingerprint_at(metadata: &std::fs::Metadata, now_ns: i128) -> Option<String> {
5473 use std::os::unix::fs::MetadataExt as _;
5474
5475 let mtime_ns = i128::from(metadata.mtime()) * 1_000_000_000 + i128::from(metadata.mtime_nsec());
5483 let ctime_ns = i128::from(metadata.ctime()) * 1_000_000_000 + i128::from(metadata.ctime_nsec());
5484 let newest = mtime_ns.max(ctime_ns);
5485 if newest < 0 || now_ns.checked_sub(newest)? < 2_000_000_000 {
5486 return None;
5487 }
5488 Some(format!(
5489 "unix-v1:{:x}:{:x}:{:x}:{:x}:{:x}:{:x}:{:x}",
5490 metadata.dev(),
5491 metadata.ino(),
5492 metadata.len(),
5493 metadata.mtime(),
5494 metadata.mtime_nsec(),
5495 metadata.ctime(),
5496 metadata.ctime_nsec(),
5497 ))
5498}
5499
5500#[cfg(unix)]
5501fn v2_scan_fingerprint(metadata: &std::fs::Metadata) -> Option<String> {
5502 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?;
5503 let now_ns = i128::from(now.as_secs()) * 1_000_000_000 + i128::from(now.subsec_nanos());
5504 v2_scan_fingerprint_at(metadata, now_ns)
5505}
5506
5507#[cfg(not(unix))]
5508fn v2_scan_fingerprint(_metadata: &std::fs::Metadata) -> Option<String> {
5509 None
5512}
5513
5514fn v2_local_files_cached(
5515 store: &Store,
5516 prior_cache: Option<(&str, &std::collections::BTreeMap<String, V2ScanCacheFile>)>,
5517) -> LinkResult<V2LocalView> {
5518 let policy = crate::linkmd_sync_policy::load(store)
5519 .map_err(|message| LinkError::InvalidPack { message })?;
5520 let prior_cache = prior_cache
5521 .filter(|(digest, _)| *digest == policy.digest.as_str())
5522 .map(|(_, cache)| cache);
5523 let non_markdown_asset_paths = crate::assets::read_manifest(store)
5524 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5525 .into_iter()
5526 .filter(|asset| !is_markdown_asset_path(&asset.path))
5527 .map(|asset| asset.path)
5528 .collect::<std::collections::BTreeSet<_>>();
5529 let mut result = std::collections::BTreeMap::new();
5530 let mut scan_cache = std::collections::BTreeMap::new();
5531 let mut eligibility = std::collections::BTreeMap::new();
5532 let mut withheld_links = Vec::<V2WithheldLink>::new();
5533 let mut total = 0_u64;
5534 let mut paths = vec![PathBuf::from("DB.md")];
5535 paths.extend(store.walk()?);
5536 for relative in paths {
5537 let path = relative.to_string_lossy().replace('\\', "/");
5538 if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5540 continue;
5541 }
5542 if non_markdown_asset_paths.contains(&path) {
5543 continue;
5544 }
5545 crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5546 path: error.to_string(),
5547 })?;
5548 let riding = !policy.keeps_home(&path);
5549 eligibility.insert(path.clone(), riding);
5550 if !riding {
5551 continue;
5552 }
5553 let remaining = MAX_STORE_BYTES.saturating_sub(total);
5554 let mut file = store.open_regular(&relative)?;
5555 let before = file.metadata()?;
5556 if before.len() > remaining {
5557 return Err(LinkError::PushTooLarge {
5558 detail: format!("more than {MAX_STORE_BYTES} uncompressed bytes"),
5559 });
5560 }
5561 let fingerprint = v2_scan_fingerprint(&before);
5562 if let (Some(fingerprint), Some(cached)) = (
5563 fingerprint.as_deref(),
5564 prior_cache.and_then(|cache| cache.get(&path)),
5565 ) {
5566 if cached.fingerprint == fingerprint && cached.bytes == before.len() {
5567 total = total
5568 .checked_add(cached.bytes)
5569 .ok_or_else(|| LinkError::PushTooLarge {
5570 detail: "v2 local byte count overflow".to_string(),
5571 })?;
5572 result.insert(path.clone(), (cached.sha256.clone(), cached.bytes));
5573 for target in &cached.withheld_targets {
5574 withheld_links.push(V2WithheldLink {
5575 source: path.clone(),
5576 target: target.clone(),
5577 });
5578 }
5579 scan_cache.insert(path, cached.clone());
5580 continue;
5581 }
5582 }
5583 let mut bytes = Vec::with_capacity(before.len().min(8 * 1024 * 1024) as usize);
5584 Read::by_ref(&mut file)
5585 .take(remaining.saturating_add(1))
5586 .read_to_end(&mut bytes)?;
5587 if bytes.len() as u64 > remaining {
5588 return Err(LinkError::PushTooLarge {
5589 detail: format!("more than {MAX_STORE_BYTES} uncompressed bytes"),
5590 });
5591 }
5592 total = total
5593 .checked_add(bytes.len() as u64)
5594 .ok_or_else(|| LinkError::PushTooLarge {
5595 detail: "v2 local byte count overflow".to_string(),
5596 })?;
5597 if total > MAX_STORE_BYTES {
5598 return Err(LinkError::PushTooLarge {
5599 detail: format!("{total} uncompressed bytes"),
5600 });
5601 }
5602 if std::str::from_utf8(&bytes).is_err() {
5603 return Err(LinkError::NotUtf8 { path });
5604 }
5605 let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5606 let targets = crate::store::extract_edge_targets(text)
5611 .into_iter()
5612 .map(|target| format!("{target}.md"))
5613 .filter(|target| policy.keeps_home(target))
5614 .collect::<Vec<_>>();
5615 for target in &targets {
5616 withheld_links.push(V2WithheldLink {
5617 source: path.clone(),
5618 target: target.clone(),
5619 });
5620 }
5621 let sha256 = content_sha256(&bytes);
5622 result.insert(path.clone(), (sha256.clone(), bytes.len() as u64));
5623 let after = file.metadata()?;
5624 if before.len() == bytes.len() as u64
5625 && v2_scan_fingerprint(&before) == v2_scan_fingerprint(&after)
5626 {
5627 if let Some(fingerprint) = v2_scan_fingerprint(&after) {
5628 scan_cache.insert(
5629 path,
5630 V2ScanCacheFile {
5631 fingerprint,
5632 sha256,
5633 bytes: bytes.len() as u64,
5634 withheld_targets: targets,
5635 },
5636 );
5637 }
5638 }
5639 }
5640 withheld_links.sort();
5641 withheld_links.dedup();
5642 Ok(V2LocalView {
5643 riding: result,
5644 scan_cache,
5645 eligibility,
5646 policy,
5647 withheld_links,
5648 })
5649}
5650
5651fn is_markdown_asset_path(path: &str) -> bool {
5652 path.to_ascii_lowercase().ends_with(".md")
5653}
5654
5655fn v2_content_put_operation_kind(
5656 path: &str,
5657 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
5658) -> &'static str {
5659 if local_assets.contains_key(path) && is_markdown_asset_path(path) {
5660 "put_asset_content"
5661 } else {
5662 "put"
5663 }
5664}
5665
5666fn v2_withdrawal_includes_content(
5667 path: &str,
5668 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
5669) -> bool {
5670 local_assets
5671 .get(path)
5672 .is_none_or(|asset| is_markdown_asset_path(&asset.path))
5673}
5674
5675fn verify_v2_markdown_asset_content_bindings(
5676 content: &std::collections::BTreeMap<String, V2BaselineFile>,
5677 assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
5678) -> LinkResult<()> {
5679 for (path, asset) in assets {
5680 if !is_markdown_asset_path(path) {
5681 continue;
5682 }
5683 let content_file = content.get(path);
5684 let exact = content_file
5685 .is_some_and(|file| file.sha256 == asset.blob_sha256 && file.bytes == asset.bytes);
5686 if (asset.disposition == "hosted" && !exact)
5687 || (asset.disposition == "withheld" && content_file.is_some())
5688 {
5689 return Err(invalid_feed(format!(
5690 "markdown asset `{path}` is not exactly bound across the signed content and asset views"
5691 )));
5692 }
5693 }
5694 Ok(())
5695}
5696
5697fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5698 v2_local_files_cached(store, None)
5699}
5700
5701#[derive(Debug, Clone, Deserialize)]
5702struct V2DownloadItem {
5703 path: String,
5704 sha256: String,
5705 bytes: u64,
5706 url: String,
5707 method: String,
5708}
5709
5710#[derive(Debug, Deserialize)]
5711struct V2DownloadWindow {
5712 v: u8,
5713 commit: String,
5714 downloads: Vec<V2DownloadItem>,
5715}
5716
5717#[derive(Debug, Deserialize)]
5718struct V2BulkStreamHeader {
5719 v: u8,
5720 path: String,
5721 sha256: String,
5722 bytes: u64,
5723}
5724
5725fn parse_v2_bulk_stream(
5726 bytes: &[u8],
5727 expected: &[(&String, &V2BaselineFile)],
5728) -> LinkResult<Vec<(String, Vec<u8>)>> {
5729 if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5730 return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5731 }
5732 let mut cursor = V2_BULK_STREAM_MAGIC.len();
5733 let mut result = Vec::with_capacity(expected.len());
5734 for (expected_path, expected_file) in expected {
5735 let length_bytes = bytes
5736 .get(cursor..cursor + 4)
5737 .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5738 cursor += 4;
5739 let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5740 if header_len == 0 || header_len > 4 * 1024 {
5741 return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5742 }
5743 let header_bytes = bytes
5744 .get(cursor..cursor + header_len)
5745 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5746 cursor += header_len;
5747 let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5748 .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5749 if header.v != 2
5750 || &header.path != *expected_path
5751 || header.sha256 != expected_file.sha256
5752 || header.bytes != expected_file.bytes
5753 || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5754 {
5755 return Err(invalid_feed(
5756 "v2 bulk stream frame differs from its proven manifest entry",
5757 ));
5758 }
5759 let body_len = usize::try_from(header.bytes)
5760 .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5761 let body = bytes
5762 .get(cursor..cursor + body_len)
5763 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5764 cursor += body_len;
5765 if content_sha256(body) != header.sha256 {
5766 return Err(invalid_feed(
5767 "v2 bulk stream file differs from its proven manifest entry",
5768 ));
5769 }
5770 result.push((header.path, body.to_vec()));
5771 }
5772 if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5773 return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5774 }
5775 cursor += 4;
5776 if cursor != bytes.len() {
5777 return Err(invalid_feed("v2 bulk stream carries trailing data"));
5778 }
5779 Ok(result)
5780}
5781
5782fn download_v2_bulk_stream(
5783 cfg: &HubConfig,
5784 brain: &str,
5785 pointer: &V2PointerBody,
5786 pending: &[(&String, &V2BaselineFile)],
5787) -> LinkResult<Vec<(String, Vec<u8>)>> {
5788 let claims = pending
5789 .iter()
5790 .map(|(path, file)| {
5791 Ok(json!({
5792 "path": path,
5793 "sha256": file.sha256,
5794 "bytes": file.bytes,
5795 "proof": file.proof.as_ref().ok_or_else(|| {
5796 invalid_feed("v2 manifest omitted a bulk-stream proof")
5797 })?,
5798 }))
5799 })
5800 .collect::<LinkResult<Vec<_>>>()?;
5801 let raw = request_raw_retryable_read(
5802 cfg,
5803 "POST",
5804 &format!("/api/hub/brains/{brain}/v2/stream"),
5805 Some(&json!({
5806 "commit": pointer.commit_hash,
5807 "files": claims,
5808 })),
5809 Auth::Required,
5810 V2_BULK_STREAM_RESPONSE_BYTES,
5811 )?;
5812 let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5813 parse_v2_bulk_stream(&body, pending)
5814}
5815
5816fn request_capped_retryable_read(
5817 cfg: &HubConfig,
5818 method: &str,
5819 path: &str,
5820 body: Option<&Value>,
5821 auth: Auth,
5822 max_response_bytes: u64,
5823) -> LinkResult<HubResponse> {
5824 let raw = request_raw_retryable_read(cfg, method, path, body, auth, max_response_bytes)?;
5825 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
5826 Ok(HubResponse {
5827 status: raw.status,
5828 body: parsed,
5829 })
5830}
5831
5832fn prepare_v2_downloads(
5833 cfg: &HubConfig,
5834 brain: &str,
5835 pointer: &V2PointerBody,
5836 pending: &[(&String, &V2BaselineFile)],
5837) -> LinkResult<Vec<V2DownloadItem>> {
5838 let mut result = Vec::with_capacity(pending.len());
5839 for chunk in pending.chunks(128) {
5840 let claims = chunk
5841 .iter()
5842 .map(|(path, file)| {
5843 Ok(json!({
5844 "path": path,
5845 "sha256": file.sha256,
5846 "bytes": file.bytes,
5847 "proof": file.proof.as_ref().ok_or_else(|| {
5848 invalid_feed("v2 manifest omitted a download proof")
5849 })?,
5850 }))
5851 })
5852 .collect::<LinkResult<Vec<_>>>()?;
5853 let value = ensure_ok(
5854 request_capped_retryable_read(
5855 cfg,
5856 "POST",
5857 &format!("/api/hub/brains/{brain}/v2/downloads"),
5858 Some(&json!({
5859 "commit": pointer.commit_hash,
5860 "files": claims,
5861 })),
5862 Auth::Required,
5863 MAX_FEED_RESPONSE_BYTES,
5864 )?,
5865 "prepare v2 blob downloads",
5866 )?;
5867 let window: V2DownloadWindow = serde_json::from_value(value)
5868 .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5869 if window.v != 2
5870 || window.commit != pointer.commit_hash
5871 || window.downloads.len() != chunk.len()
5872 {
5873 return Err(invalid_feed(
5874 "v2 download window is not bound to the requested files",
5875 ));
5876 }
5877 let mut by_path = window
5878 .downloads
5879 .into_iter()
5880 .map(|item| (item.path.clone(), item))
5881 .collect::<std::collections::BTreeMap<_, _>>();
5882 if by_path.len() != chunk.len() {
5883 return Err(invalid_feed("v2 download window repeats a path"));
5884 }
5885 for (path, file) in chunk {
5886 let item = by_path
5887 .remove(*path)
5888 .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5889 if item.method != "GET"
5890 || item.sha256 != file.sha256
5891 || item.bytes != file.bytes
5892 || item.url.is_empty()
5893 {
5894 return Err(invalid_feed(
5895 "v2 download capability differs from its proven file",
5896 ));
5897 }
5898 result.push(item);
5899 }
5900 }
5901 Ok(result)
5902}
5903
5904fn prepare_v2_asset_downloads(
5905 cfg: &HubConfig,
5906 brain: &str,
5907 pointer: &V2PointerBody,
5908 pending: &[(&String, &V2BaselineAsset)],
5909) -> LinkResult<Vec<V2DownloadItem>> {
5910 let mut result = Vec::with_capacity(pending.len());
5911 for chunk in pending.chunks(128) {
5912 let claims = chunk
5913 .iter()
5914 .map(|(path, asset)| {
5915 json!({
5916 "path": path,
5917 "sha256": asset.blob_sha256,
5918 "bytes": asset.bytes,
5919 "leaf_hash": asset.leaf_hash,
5920 })
5921 })
5922 .collect::<Vec<_>>();
5923 let value = ensure_ok(
5924 request_capped_retryable_read(
5925 cfg,
5926 "POST",
5927 &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5928 Some(&json!({
5929 "commit": pointer.commit_hash,
5930 "assets": claims,
5931 })),
5932 Auth::Required,
5933 MAX_FEED_RESPONSE_BYTES,
5934 )?,
5935 "prepare v2 asset downloads",
5936 )?;
5937 let window: V2DownloadWindow = serde_json::from_value(value)
5938 .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5939 if window.v != 2
5940 || window.commit != pointer.commit_hash
5941 || window.downloads.len() != chunk.len()
5942 {
5943 return Err(invalid_feed(
5944 "v2 asset download window is not bound to the requested assets",
5945 ));
5946 }
5947 let mut by_path = window
5948 .downloads
5949 .into_iter()
5950 .map(|item| (item.path.clone(), item))
5951 .collect::<std::collections::BTreeMap<_, _>>();
5952 if by_path.len() != chunk.len() {
5953 return Err(invalid_feed("v2 asset download window repeats a path"));
5954 }
5955 for (path, asset) in chunk {
5956 let item = by_path
5957 .remove(*path)
5958 .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5959 if item.method != "GET"
5960 || item.sha256 != asset.blob_sha256
5961 || item.bytes != asset.bytes
5962 || item.url.is_empty()
5963 {
5964 return Err(invalid_feed(
5965 "v2 asset download capability differs from its signed leaf",
5966 ));
5967 }
5968 result.push(item);
5969 }
5970 }
5971 Ok(result)
5972}
5973
5974#[cfg(any(unix, windows))]
5975fn stage_v2_asset_download_window(
5976 cfg: &HubConfig,
5977 brain: &str,
5978 pointer: &V2PointerBody,
5979 cache_dir: &Path,
5980 pending: &[(&String, &V2BaselineAsset)],
5981) -> LinkResult<Vec<V2StagedFile>> {
5982 if pending.is_empty() {
5983 return Ok(Vec::new());
5984 }
5985 if pending.len() > V2_DOWNLOAD_CAPABILITY_FILES {
5986 return Err(invalid_feed("v2 asset capability window is oversized"));
5987 }
5988
5989 let mut last_error = None;
5990 for retry_delay in V2_DOWNLOAD_CAPABILITY_BACKOFF_MS
5991 .iter()
5992 .copied()
5993 .map(Some)
5994 .chain(std::iter::once(None))
5995 {
5996 let downloads = prepare_v2_asset_downloads(cfg, brain, pointer, pending)?;
6001 let mut unique = std::collections::BTreeMap::<String, V2DownloadItem>::new();
6002 for item in downloads {
6003 match unique.get(&item.sha256) {
6004 Some(prior) if prior.bytes != item.bytes => {
6005 return Err(invalid_feed(
6006 "one v2 asset hash has conflicting byte lengths",
6007 ));
6008 }
6009 Some(_) => {}
6010 None => {
6011 unique.insert(item.sha256.clone(), item);
6012 }
6013 }
6014 }
6015 let downloads = unique.into_values().collect::<Vec<_>>();
6016 let next = std::sync::atomic::AtomicUsize::new(0);
6017 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6018 let mut results = std::iter::repeat_with(|| None)
6019 .take(downloads.len())
6020 .collect::<Vec<Option<LinkResult<PathBuf>>>>();
6021 std::thread::scope(|scope| {
6022 let (sender, receiver) = std::sync::mpsc::channel();
6023 for _ in 0..worker_count {
6024 let sender = sender.clone();
6025 let downloads = &downloads;
6026 let next = &next;
6027 scope.spawn(move || loop {
6028 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6029 let Some(item) = downloads.get(index) else {
6030 break;
6031 };
6032 let result = download_presigned_to_cache(
6033 cfg,
6034 &item.url,
6035 cache_dir,
6036 &item.sha256,
6037 item.bytes,
6038 );
6039 if sender.send((index, result)).is_err() {
6040 break;
6041 }
6042 });
6043 }
6044 drop(sender);
6045 for (index, result) in receiver {
6046 results[index] = Some(result);
6047 }
6048 });
6049
6050 let mut failed = None;
6051 for result in results {
6052 match result {
6053 Some(Ok(_)) => {}
6054 Some(Err(error)) if failed.is_none() => failed = Some(error),
6055 Some(Err(_)) => {}
6056 None if failed.is_none() => {
6057 failed = Some(LinkError::Transport {
6058 hub: cfg.hub.clone(),
6059 message: "a bounded v2 asset worker stopped before reporting its result"
6060 .to_string(),
6061 });
6062 }
6063 None => {}
6064 }
6065 }
6066 if let Some(error) = failed {
6067 last_error = Some(error);
6068 if let Some(milliseconds) = retry_delay {
6069 std::thread::sleep(std::time::Duration::from_millis(milliseconds));
6070 continue;
6071 }
6072 break;
6073 }
6074
6075 return pending
6076 .iter()
6077 .map(|(path, asset)| {
6078 let source = cache_dir.join(&asset.blob_sha256);
6079 if !cached_blob_is_exact(&source, &asset.blob_sha256, asset.bytes)? {
6080 return Err(invalid_feed(
6081 "v2 asset download cache omitted a proven blob",
6082 ));
6083 }
6084 Ok(V2StagedFile {
6085 path: (*path).clone(),
6086 source,
6087 sha256: asset.blob_sha256.clone(),
6088 bytes: asset.bytes,
6089 })
6090 })
6091 .collect();
6092 }
6093 Err(last_error
6094 .unwrap_or_else(|| invalid_feed("v2 asset capability window made no download attempt")))
6095}
6096
6097fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
6098 let bytes = get_presigned(cfg, &item.url)?;
6099 if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
6100 return Err(invalid_feed("v2 blob differs from its proven path entry"));
6101 }
6102 Ok(bytes)
6103}
6104
6105#[derive(Debug, Clone)]
6106struct V2StagedFile {
6107 path: String,
6108 source: PathBuf,
6109 sha256: String,
6110 bytes: u64,
6111}
6112
6113#[cfg(unix)]
6114fn v2_download_cache_dir(
6115 cfg: &HubConfig,
6116 brain: &str,
6117 pointer: &V2PointerBody,
6118) -> LinkResult<PathBuf> {
6119 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
6120}
6121
6122#[cfg(unix)]
6123fn v2_download_cache_dir_for(
6124 cfg: &HubConfig,
6125 brain: &str,
6126 transaction: &str,
6127) -> LinkResult<PathBuf> {
6128 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6129 return Err(invalid_feed("v2 download cache address is invalid"));
6130 }
6131 let path = cfg
6132 .state_dir
6133 .join("downloads")
6134 .join(brain)
6135 .join(transaction);
6136 let directory = open_or_create_dir_nofollow(&path)?;
6137 use std::os::fd::AsRawFd as _;
6138 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
6139 return Err(std::io::Error::last_os_error().into());
6140 }
6141 directory.sync_all()?;
6142 Ok(path)
6143}
6144
6145#[cfg(windows)]
6146fn v2_download_cache_dir(
6147 cfg: &HubConfig,
6148 brain: &str,
6149 pointer: &V2PointerBody,
6150) -> LinkResult<PathBuf> {
6151 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
6152}
6153
6154#[cfg(windows)]
6155fn v2_download_cache_dir_for(
6156 cfg: &HubConfig,
6157 brain: &str,
6158 transaction: &str,
6159) -> LinkResult<PathBuf> {
6160 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6161 return Err(invalid_feed("v2 download cache address is invalid"));
6162 }
6163 let path = cfg
6164 .state_dir
6165 .join("downloads")
6166 .join(brain)
6167 .join(transaction);
6168 crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
6169 crate::fsx::open_directory_nofollow(&path)?;
6170 Ok(path)
6171}
6172
6173#[cfg(unix)]
6174fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
6175 use std::os::fd::AsRawFd as _;
6176 let parent = cfg.state_dir.join("downloads").join(brain);
6177 let Ok(directory) = open_existing_dir_nofollow(&parent) else {
6178 return;
6179 };
6180 let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
6181 return;
6182 };
6183 let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
6184 let _ = directory.sync_all();
6185}
6186
6187#[cfg(windows)]
6188fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
6189 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6190 return;
6191 }
6192 let parent = cfg.state_dir.join("downloads").join(brain);
6193 let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
6194 return;
6195 };
6196 let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
6197}
6198
6199#[cfg(not(any(unix, windows)))]
6200fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
6201
6202#[cfg(not(any(unix, windows)))]
6203fn v2_download_cache_dir_for(
6204 _cfg: &HubConfig,
6205 _brain: &str,
6206 _transaction: &str,
6207) -> LinkResult<PathBuf> {
6208 Err(LinkError::UnsupportedPlatform {
6209 operation: "resumable v2 download staging",
6210 })
6211}
6212
6213#[cfg(any(unix, windows))]
6214fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
6215 let file = match crate::fsx::open_regular_nofollow(path) {
6216 Ok(file) => file,
6217 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6218 Err(error) => return Err(error.into()),
6219 };
6220 if file.metadata()?.len() != bytes {
6221 return Ok(false);
6222 }
6223 Ok(content_sha256_reader(file)? == sha256)
6224}
6225
6226#[cfg(any(unix, windows))]
6227fn cache_v2_blob_bytes(
6228 cache_dir: &Path,
6229 sha256: &str,
6230 expected_bytes: u64,
6231 bytes: &[u8],
6232) -> LinkResult<PathBuf> {
6233 if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
6234 return Err(invalid_feed("v2 cached blob differs from its declaration"));
6235 }
6236 let path = cache_dir.join(sha256);
6237 if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
6238 crate::fsx::write_atomic(&path, bytes)?;
6239 }
6240 Ok(path)
6241}
6242
6243#[cfg(not(any(unix, windows)))]
6244fn cache_v2_blob_bytes(
6245 _cache_dir: &Path,
6246 _sha256: &str,
6247 _expected_bytes: u64,
6248 _bytes: &[u8],
6249) -> LinkResult<PathBuf> {
6250 Err(LinkError::UnsupportedPlatform {
6251 operation: "resumable v2 download staging",
6252 })
6253}
6254
6255#[cfg(unix)]
6256fn download_presigned_to_cache(
6257 cfg: &HubConfig,
6258 url: &str,
6259 cache_dir: &Path,
6260 sha256: &str,
6261 expected_bytes: u64,
6262) -> LinkResult<PathBuf> {
6263 use std::os::fd::{AsRawFd as _, FromRawFd as _};
6264
6265 let target = cache_dir.join(sha256);
6266 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6267 return Ok(target);
6268 }
6269 let directory = open_existing_dir_nofollow(cache_dir)?;
6270 let mut nonce = [0_u8; 16];
6271 ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
6272 .map_err(|_| invalid_feed("could not mint a download cache name"))?;
6273 let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
6274 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
6275 let fd = unsafe {
6276 libc::openat(
6277 directory.as_raw_fd(),
6278 temp.as_ptr(),
6279 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6280 0o600,
6281 )
6282 };
6283 if fd < 0 {
6284 return Err(std::io::Error::last_os_error().into());
6285 }
6286 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
6287 let response = match presigned_agent(cfg, url)?.get(url).call() {
6288 Ok(response) => response,
6289 Err(ureq::Error::Status(_, response)) => {
6290 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6291 return Err(LinkError::Http {
6292 what: "v2 direct download",
6293 status: response.status(),
6294 message: "object store rejected the download".to_string(),
6295 code: None,
6296 details: None,
6297 });
6298 }
6299 Err(ureq::Error::Transport(error)) => {
6300 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6301 return Err(LinkError::Transport {
6302 hub: cfg.hub.clone(),
6303 message: error.to_string(),
6304 });
6305 }
6306 };
6307 let mut reader = response
6308 .into_reader()
6309 .take(expected_bytes.saturating_add(1));
6310 let mut digest = Sha256::new();
6311 let mut total = 0_u64;
6312 let mut buffer = [0_u8; 64 * 1024];
6313 let write_result = (|| -> LinkResult<()> {
6318 loop {
6319 let read = reader
6320 .read(&mut buffer)
6321 .map_err(|error| LinkError::Transport {
6322 hub: cfg.hub.clone(),
6323 message: error.to_string(),
6324 })?;
6325 if read == 0 {
6326 break;
6327 }
6328 total = total.saturating_add(read as u64);
6329 digest.update(&buffer[..read]);
6330 output.write_all(&buffer[..read])?;
6331 }
6332 output.sync_all().map_err(LinkError::from)
6333 })();
6334 if let Err(error) = write_result {
6335 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6336 return Err(error);
6337 }
6338 drop(output);
6339 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6340 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6341 return Err(invalid_feed(
6342 "v2 direct download failed integrity verification",
6343 ));
6344 }
6345 let target_name = c_name(sha256.as_bytes(), sha256)?;
6346 if unsafe {
6349 libc::renameat(
6350 directory.as_raw_fd(),
6351 temp.as_ptr(),
6352 directory.as_raw_fd(),
6353 target_name.as_ptr(),
6354 )
6355 } != 0
6356 {
6357 let error = std::io::Error::last_os_error();
6358 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6359 return Err(error.into());
6360 }
6361 directory.sync_all()?;
6362 Ok(target)
6363}
6364
6365#[cfg(windows)]
6366fn download_presigned_to_cache(
6367 cfg: &HubConfig,
6368 url: &str,
6369 cache_dir: &Path,
6370 sha256: &str,
6371 expected_bytes: u64,
6372) -> LinkResult<PathBuf> {
6373 use std::fs::OpenOptions;
6374
6375 let target = cache_dir.join(sha256);
6376 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6377 return Ok(target);
6378 }
6379 let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
6383 let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
6384 let mut output = OpenOptions::new()
6385 .write(true)
6386 .create_new(true)
6387 .open(&temp)?;
6388 let response = match presigned_agent(cfg, url)?.get(url).call() {
6389 Ok(response) => response,
6390 Err(ureq::Error::Status(_, response)) => {
6391 let _ = std::fs::remove_file(&temp);
6392 return Err(LinkError::Http {
6393 what: "v2 direct download",
6394 status: response.status(),
6395 message: "object store rejected the download".to_string(),
6396 code: None,
6397 details: None,
6398 });
6399 }
6400 Err(ureq::Error::Transport(error)) => {
6401 let _ = std::fs::remove_file(&temp);
6402 return Err(LinkError::Transport {
6403 hub: cfg.hub.clone(),
6404 message: error.to_string(),
6405 });
6406 }
6407 };
6408 let mut reader = response
6409 .into_reader()
6410 .take(expected_bytes.saturating_add(1));
6411 let mut digest = Sha256::new();
6412 let mut total = 0_u64;
6413 let mut buffer = [0_u8; 64 * 1024];
6414 let copied = (|| -> LinkResult<()> {
6416 loop {
6417 let read = reader
6418 .read(&mut buffer)
6419 .map_err(|error| LinkError::Transport {
6420 hub: cfg.hub.clone(),
6421 message: error.to_string(),
6422 })?;
6423 if read == 0 {
6424 break;
6425 }
6426 total = total.saturating_add(read as u64);
6427 digest.update(&buffer[..read]);
6428 output.write_all(&buffer[..read])?;
6429 }
6430 output.sync_all()?;
6431 Ok(())
6432 })();
6433 if let Err(error) = copied {
6434 let _ = std::fs::remove_file(&temp);
6435 return Err(error);
6436 }
6437 drop(output);
6438 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6439 let _ = std::fs::remove_file(&temp);
6440 return Err(invalid_feed(
6441 "v2 direct download failed integrity verification",
6442 ));
6443 }
6444 if target.exists() {
6445 std::fs::remove_file(&target)?;
6446 }
6447 if let Err(error) = std::fs::rename(&temp, &target) {
6448 let _ = std::fs::remove_file(&temp);
6449 return Err(error.into());
6450 }
6451 Ok(target)
6452}
6453
6454#[cfg(not(any(unix, windows)))]
6455fn download_presigned_to_cache(
6456 _cfg: &HubConfig,
6457 _url: &str,
6458 _cache_dir: &Path,
6459 _sha256: &str,
6460 _expected_bytes: u64,
6461) -> LinkResult<PathBuf> {
6462 Err(LinkError::UnsupportedPlatform {
6463 operation: "resumable v2 download staging",
6464 })
6465}
6466
6467fn download_v2_blobs(
6468 cfg: &HubConfig,
6469 brain: &str,
6470 pointer: &V2PointerBody,
6471 pending: Vec<(&String, &V2BaselineFile)>,
6472) -> LinkResult<Vec<(String, Vec<u8>)>> {
6473 if pending.is_empty() {
6474 return Ok(Vec::new());
6475 }
6476 let expected_order = pending
6477 .iter()
6478 .map(|(path, _)| (*path).clone())
6479 .collect::<Vec<_>>();
6480 let mut streamed = std::collections::BTreeMap::new();
6481 let mut direct = Vec::new();
6482 let mut window = Vec::new();
6483 let mut window_bytes = 0_u64;
6484 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6485 window_bytes: &mut u64,
6486 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
6487 -> LinkResult<()> {
6488 if window.is_empty() {
6489 return Ok(());
6490 }
6491 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
6492 if streamed.insert(path, bytes).is_some() {
6493 return Err(invalid_feed("v2 bulk streams repeated a path"));
6494 }
6495 }
6496 window.clear();
6497 *window_bytes = 0;
6498 Ok(())
6499 };
6500 for &(path, file) in &pending {
6501 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6502 flush(&mut window, &mut window_bytes, &mut streamed)?;
6503 direct.push((path, file));
6504 continue;
6505 }
6506 if window.len() == V2_BULK_STREAM_FILES
6507 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6508 {
6509 flush(&mut window, &mut window_bytes, &mut streamed)?;
6510 }
6511 window.push((path, file));
6512 window_bytes += file.bytes;
6513 }
6514 flush(&mut window, &mut window_bytes, &mut streamed)?;
6515
6516 let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
6517 let next = std::sync::atomic::AtomicUsize::new(0);
6518 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6519 let mut results = std::iter::repeat_with(|| None)
6520 .take(downloads.len())
6521 .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
6522 std::thread::scope(|scope| {
6523 let (sender, receiver) = std::sync::mpsc::channel();
6524 for _ in 0..worker_count {
6525 let sender = sender.clone();
6526 let downloads = &downloads;
6527 let next = &next;
6528 scope.spawn(move || loop {
6529 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6530 let Some(item) = downloads.get(index) else {
6531 break;
6532 };
6533 let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
6534 if sender.send((index, result)).is_err() {
6535 break;
6536 }
6537 });
6538 }
6539 drop(sender);
6540 for (index, result) in receiver {
6541 results[index] = Some(result);
6542 }
6543 });
6544 for result in results.into_iter().map(|result| {
6545 result.ok_or_else(|| LinkError::Transport {
6546 hub: cfg.hub.clone(),
6547 message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
6548 })?
6549 }) {
6550 let (path, bytes) = result?;
6551 if streamed.insert(path, bytes).is_some() {
6552 return Err(invalid_feed("v2 download lanes repeated a path"));
6553 }
6554 }
6555 expected_order
6556 .into_iter()
6557 .map(|path| {
6558 streamed
6559 .remove(&path)
6560 .map(|bytes| (path, bytes))
6561 .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
6562 })
6563 .collect()
6564}
6565
6566#[cfg(any(unix, windows))]
6570fn queue_v2_bulk_window<'a>(
6571 cache_dir: &Path,
6572 window: &mut Vec<(&'a String, &'a V2BaselineFile)>,
6573 window_bytes: &mut u64,
6574 staged: &mut std::collections::BTreeMap<String, V2StagedFile>,
6575 missing_windows: &mut Vec<Vec<(&'a String, &'a V2BaselineFile)>>,
6576) -> LinkResult<()> {
6577 if window.is_empty() {
6578 return Ok(());
6579 }
6580 let missing = window
6581 .iter()
6582 .filter_map(|(path, file)| {
6583 let target = cache_dir.join(&file.sha256);
6584 match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6585 Ok(true) => {
6586 staged.insert(
6587 (*path).clone(),
6588 V2StagedFile {
6589 path: (*path).clone(),
6590 source: target,
6591 sha256: file.sha256.clone(),
6592 bytes: file.bytes,
6593 },
6594 );
6595 None
6596 }
6597 Ok(false) => Some(Ok((*path, *file))),
6598 Err(error) => Some(Err(error)),
6599 }
6600 })
6601 .collect::<LinkResult<Vec<_>>>()?;
6602 if !missing.is_empty() {
6603 missing_windows.push(missing);
6604 }
6605 window.clear();
6606 *window_bytes = 0;
6607 Ok(())
6608}
6609
6610#[cfg(any(unix, windows))]
6611fn stage_v2_bulk_windows<'a>(
6612 cfg: &HubConfig,
6613 brain: &str,
6614 pointer: &V2PointerBody,
6615 cache_dir: &Path,
6616 windows: Vec<Vec<(&'a String, &'a V2BaselineFile)>>,
6617 staged: &mut std::collections::BTreeMap<String, V2StagedFile>,
6618) -> LinkResult<()> {
6619 let next = std::sync::atomic::AtomicUsize::new(0);
6620 let worker_count = windows.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6621 let mut first_error = None;
6622 std::thread::scope(|scope| {
6623 let (sender, receiver) = std::sync::mpsc::channel();
6624 for _ in 0..worker_count {
6625 let sender = sender.clone();
6626 let windows = &windows;
6627 let next = &next;
6628 scope.spawn(move || loop {
6629 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6630 let Some(window) = windows.get(index) else {
6631 break;
6632 };
6633 let result = download_v2_bulk_stream(cfg, brain, pointer, window);
6634 if sender.send((index, result)).is_err() {
6635 break;
6636 }
6637 });
6638 }
6639 drop(sender);
6640 for (index, result) in receiver {
6641 match result {
6642 Ok(files) => {
6643 for (path, bytes) in files {
6644 let Some(file) = windows[index].iter().find_map(|(expected_path, file)| {
6645 (*expected_path == &path).then_some(*file)
6646 }) else {
6647 first_error.get_or_insert_with(|| {
6648 invalid_feed("v2 stream returned an unrequested cache path")
6649 });
6650 continue;
6651 };
6652 match cache_v2_blob_bytes(cache_dir, &file.sha256, file.bytes, &bytes) {
6653 Ok(source) => {
6654 if staged
6655 .insert(
6656 path.clone(),
6657 V2StagedFile {
6658 path,
6659 source,
6660 sha256: file.sha256.clone(),
6661 bytes: file.bytes,
6662 },
6663 )
6664 .is_some()
6665 {
6666 first_error.get_or_insert_with(|| {
6667 invalid_feed("v2 bulk streams repeated a path")
6668 });
6669 }
6670 }
6671 Err(error) => {
6672 first_error.get_or_insert(error);
6673 }
6674 }
6675 }
6676 }
6677 Err(error) => {
6678 first_error.get_or_insert(error);
6679 }
6680 }
6681 }
6682 });
6683 match first_error {
6684 Some(error) => Err(error),
6685 None => Ok(()),
6686 }
6687}
6688
6689#[cfg(any(unix, windows))]
6690fn stage_v2_blobs(
6691 cfg: &HubConfig,
6692 brain: &str,
6693 pointer: &V2PointerBody,
6694 pending: Vec<(&String, &V2BaselineFile)>,
6695) -> LinkResult<Vec<V2StagedFile>> {
6696 let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6697 let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6698 let mut direct = Vec::new();
6699 let mut window = Vec::new();
6700 let mut missing_windows = Vec::new();
6701 let mut window_bytes = 0_u64;
6702 for &(path, file) in &pending {
6703 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6704 queue_v2_bulk_window(
6705 &cache_dir,
6706 &mut window,
6707 &mut window_bytes,
6708 &mut staged,
6709 &mut missing_windows,
6710 )?;
6711 direct.push((path, file));
6712 continue;
6713 }
6714 if window.len() == V2_BULK_STREAM_FILES
6715 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6716 {
6717 queue_v2_bulk_window(
6718 &cache_dir,
6719 &mut window,
6720 &mut window_bytes,
6721 &mut staged,
6722 &mut missing_windows,
6723 )?;
6724 }
6725 window.push((path, file));
6726 window_bytes += file.bytes;
6727 }
6728 queue_v2_bulk_window(
6729 &cache_dir,
6730 &mut window,
6731 &mut window_bytes,
6732 &mut staged,
6733 &mut missing_windows,
6734 )?;
6735 stage_v2_bulk_windows(
6736 cfg,
6737 brain,
6738 pointer,
6739 &cache_dir,
6740 missing_windows,
6741 &mut staged,
6742 )?;
6743 for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6744 let source =
6745 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6746 staged.insert(
6747 item.path.clone(),
6748 V2StagedFile {
6749 path: item.path,
6750 source,
6751 sha256: item.sha256,
6752 bytes: item.bytes,
6753 },
6754 );
6755 }
6756 pending
6757 .into_iter()
6758 .map(|(path, _)| {
6759 staged
6760 .remove(path)
6761 .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6762 })
6763 .collect()
6764}
6765
6766#[cfg(not(any(unix, windows)))]
6767fn stage_v2_blobs(
6768 _cfg: &HubConfig,
6769 _brain: &str,
6770 _pointer: &V2PointerBody,
6771 _pending: Vec<(&String, &V2BaselineFile)>,
6772) -> LinkResult<Vec<V2StagedFile>> {
6773 Err(LinkError::UnsupportedPlatform {
6774 operation: "resumable v2 download staging",
6775 })
6776}
6777
6778const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6779const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6780const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6781
6782#[derive(Debug, Clone, Deserialize, Serialize)]
6783struct V2ConflictCoordinate {
6784 sha256: Option<String>,
6785 bytes: Option<u64>,
6786 file: Option<String>,
6787}
6788
6789#[derive(Debug, Clone, Deserialize, Serialize)]
6790struct V2ConflictFile {
6791 path: String,
6792 base: V2ConflictCoordinate,
6793 local: V2ConflictCoordinate,
6794 remote: V2ConflictCoordinate,
6795}
6796
6797#[derive(Debug, Clone, Deserialize, Serialize)]
6798struct V2ConflictPlan {
6799 v: u8,
6800 class: String,
6801 bundle: String,
6802 brain: String,
6803 origin: String,
6804 created_unix: u64,
6805 expires_unix: u64,
6806 base_seq: Option<u64>,
6807 base_commit: Option<String>,
6808 remote_seq: u64,
6809 remote_commit: Option<String>,
6810 remote_content_root: Option<String>,
6811 view_kind: String,
6812 view_revision: String,
6813 files: Vec<V2ConflictFile>,
6814}
6815
6816fn v2_take_remote_selection(
6817 files: &[V2ConflictFile],
6818 current: &std::collections::BTreeMap<String, V2BaselineFile>,
6819) -> LinkResult<(
6820 std::collections::BTreeMap<String, V2BaselineFile>,
6821 Vec<String>,
6822)> {
6823 let mut selected = std::collections::BTreeMap::new();
6824 let mut deleted = Vec::new();
6825 for file in files {
6826 match (&file.remote.sha256, file.remote.bytes) {
6827 (Some(sha256), Some(bytes)) => {
6828 let proven = current.get(&file.path).ok_or_else(|| {
6829 invalid_feed("conflict remote coordinate disappeared from the exact head")
6830 })?;
6831 if proven.sha256 != *sha256 || proven.bytes != bytes {
6832 return Err(invalid_feed(
6833 "conflict remote coordinate differs from the exact head",
6834 ));
6835 }
6836 if selected.insert(file.path.clone(), proven.clone()).is_some() {
6837 return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6838 }
6839 }
6840 (None, None) => {
6841 if current.contains_key(&file.path) {
6842 return Err(invalid_feed(
6843 "conflict remote deletion differs from the exact head",
6844 ));
6845 }
6846 deleted.push(file.path.clone());
6847 }
6848 _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6849 }
6850 }
6851 Ok((selected, deleted))
6852}
6853
6854fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6855 PathBuf::from(".dbmd")
6856 .join("conflicts")
6857 .join(bundle)
6858 .join(suffix)
6859}
6860
6861fn read_historical_conflict_blob(
6862 cfg: &HubConfig,
6863 brain: &str,
6864 baseline: &V2SyncBaseline,
6865 path: &str,
6866 file: &V2BaselineFile,
6867) -> LinkResult<Option<Vec<u8>>> {
6868 let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6869 return Ok(None);
6870 };
6871 if seq == 0 {
6872 return Ok(None);
6873 }
6874 let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6875 let endpoint = format!(
6876 "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6877 file.sha256
6878 );
6879 let history_http = hub_agent_with_timeout(cfg, std::time::Duration::from_secs(15))?;
6880 let raw = match request_raw_with_agent(
6881 cfg,
6882 &history_http,
6883 "GET",
6884 &endpoint,
6885 None,
6886 RawRequestOptions {
6887 auth: Auth::Required,
6888 max_response_bytes: file.bytes,
6889 request_id: None,
6890 retry_transport: false,
6891 },
6892 ) {
6893 Ok(raw) => raw,
6894 Err(LinkError::Transport { .. }) => return Ok(None),
6899 Err(error) => return Err(error),
6900 };
6901 if raw.status == 404 || raw.status == 403 {
6902 return Ok(None);
6903 }
6904 let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6905 if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6906 return Err(invalid_feed(
6907 "v2 conflict base failed integrity verification",
6908 ));
6909 }
6910 Ok(Some(bytes))
6911}
6912
6913fn create_v2_conflict_bundle(
6916 cfg: &HubConfig,
6917 store: &Store,
6918 head: &V2VerifiedHead,
6919 baseline: Option<&V2SyncBaseline>,
6920 local: &std::collections::BTreeMap<String, (String, u64)>,
6921 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6922 paths: &[String],
6923) -> LinkResult<(String, Vec<String>)> {
6924 let conflicts_root = Path::new(".dbmd/conflicts");
6925 store.create_dir_all(conflicts_root)?;
6926 let completed = store
6927 .directory_names(conflicts_root)?
6928 .into_iter()
6929 .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6930 .count();
6931 if completed >= V2_CONFLICT_BUNDLE_MAX {
6932 return Err(LinkError::InvalidPack {
6933 message: format!(
6934 "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6935 ),
6936 });
6937 }
6938
6939 let mut selected_paths = Vec::new();
6943 let mut selected_remote_bytes = 0_u64;
6944 for path in paths {
6945 let bytes = remote.get(path).map_or(0, |file| file.bytes);
6946 if !selected_paths.is_empty()
6947 && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6948 {
6949 break;
6950 }
6951 selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6952 selected_paths.push(path.clone());
6953 if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6954 break;
6955 }
6956 }
6957 if selected_paths.is_empty() {
6958 return Err(invalid_feed("content conflict set is empty"));
6959 }
6960 let bundle = crate::ulid::mint();
6961 let bundle_root = v2_conflict_relative(&bundle, "");
6962 store.create_dir_all(&bundle_root.join("files"))?;
6963 let pointer = head.pointer.as_ref();
6964 let remote_bytes = match pointer {
6965 Some(pointer) => download_v2_blobs(
6966 cfg,
6967 &head.brain_id,
6968 pointer,
6969 selected_paths
6970 .iter()
6971 .filter_map(|path| {
6972 remote
6973 .get(path)
6974 .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6975 .map(|file| (path, file))
6976 })
6977 .collect(),
6978 )?
6979 .into_iter()
6980 .collect::<std::collections::BTreeMap<_, _>>(),
6981 None => std::collections::BTreeMap::new(),
6982 };
6983
6984 let mut files = Vec::with_capacity(selected_paths.len());
6985 let mut historical_body_available = true;
6986 for (index, path) in selected_paths.iter().enumerate() {
6987 let base_file = baseline.and_then(|state| state.files.get(path));
6988 let base_bytes = match (historical_body_available, baseline, base_file) {
6989 (true, Some(state), Some(file)) => {
6990 let bytes = read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?;
6991 if bytes.is_none() {
6992 historical_body_available = false;
6997 }
6998 bytes
6999 }
7000 _ => None,
7001 };
7002 let local_file = local.get(path);
7003 let remote_file = remote.get(path);
7004 let remote_content = remote_bytes.get(path);
7005 let prefix = format!("files/{index:04}");
7006 let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
7007 let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
7008 let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
7009 if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
7010 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
7011 }
7012 if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
7013 let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
7014 if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
7015 return Err(LinkError::InvalidPack {
7016 message: format!("local conflict path `{path}` changed while bundling"),
7017 });
7018 }
7019 store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
7020 }
7021 if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
7022 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
7023 }
7024 files.push(V2ConflictFile {
7025 path: path.clone(),
7026 base: V2ConflictCoordinate {
7027 sha256: base_file.map(|file| file.sha256.clone()),
7028 bytes: base_file.map(|file| file.bytes),
7029 file: base_name,
7030 },
7031 local: V2ConflictCoordinate {
7032 sha256: local_file.map(|(sha256, _)| sha256.clone()),
7033 bytes: local_file.map(|(_, bytes)| *bytes),
7034 file: local_name,
7035 },
7036 remote: V2ConflictCoordinate {
7037 sha256: remote_file.map(|file| file.sha256.clone()),
7038 bytes: remote_file.map(|file| file.bytes),
7039 file: remote_name,
7040 },
7041 });
7042 }
7043 let now = SystemTime::now()
7044 .duration_since(UNIX_EPOCH)
7045 .unwrap_or_default()
7046 .as_secs();
7047 let plan = V2ConflictPlan {
7048 v: 2,
7049 class: "content_resolution_required".to_string(),
7050 bundle: bundle.clone(),
7051 brain: head.brain_id.clone(),
7052 origin: normalized_origin(&cfg.hub)?,
7053 created_unix: now,
7054 expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
7055 base_seq: baseline.and_then(|state| state.head_seq),
7056 base_commit: baseline.and_then(|state| state.commit_hash.clone()),
7057 remote_seq: pointer.map_or(0, |value| value.seq),
7058 remote_commit: pointer.map(|value| value.commit_hash.clone()),
7059 remote_content_root: pointer.and_then(|value| value.content_root.clone()),
7060 view_kind: head.view_kind.clone(),
7061 view_revision: head.view_revision.clone(),
7062 files,
7063 };
7064 let mut bytes = serde_json::to_vec_pretty(&plan)
7065 .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
7066 bytes.push(b'\n');
7067 store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
7068 Ok((bundle, selected_paths))
7069}
7070
7071fn v2_sync_pull_with_resolution(
7072 cfg: &HubConfig,
7073 requested_brain: &str,
7074 expected_head: V2VerifiedHead,
7075 out: Option<&Path>,
7076 take_remote: Option<&std::collections::BTreeSet<String>>,
7077) -> LinkResult<V2PulledSnapshot> {
7078 let dest = out
7079 .map(Path::to_path_buf)
7080 .unwrap_or_else(|| PathBuf::from(requested_brain));
7081 let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
7082 recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
7083 let head = v2_verified_head(cfg, requested_brain)?
7084 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7085 if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
7086 return Err(LinkError::RemoteAdvancedDuringSync);
7087 }
7088 let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
7089 ensure_v2_view_compatible(&head, baseline.as_ref())?;
7090 let (remote, remote_assets) = match baseline
7091 .as_ref()
7092 .filter(|state| v2_baseline_matches_head(&head, state))
7093 {
7094 Some(state) => (state.files.clone(), state.assets.clone()),
7095 None => (
7096 files_for_v2_view(
7097 &head,
7098 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7099 ),
7100 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7101 ),
7102 };
7103 verify_v2_markdown_asset_content_bindings(&remote, &remote_assets)?;
7104 let local_store = Store::open_strict(&dest).ok();
7105 ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
7110 let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
7111 if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
7112 return Err(LinkError::ScopedViewChanged);
7113 }
7114 if let Some(view) = local_view.as_mut() {
7115 remove_scoped_projection(&head, baseline.as_ref(), view)?;
7116 }
7117 let empty_local = std::collections::BTreeMap::new();
7118 let local = local_view
7119 .as_ref()
7120 .map_or(&empty_local, |view| &view.riding);
7121 let kept_home = |path: &str| {
7122 local_view
7123 .as_ref()
7124 .is_some_and(|view| view.policy.keeps_home(path))
7125 };
7126 let empty_base = std::collections::BTreeMap::new();
7127 let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
7128 let empty_base_assets = std::collections::BTreeMap::new();
7129 let base_assets = baseline
7130 .as_ref()
7131 .map_or(&empty_base_assets, |state| &state.assets);
7132 let mut local_assets = local_store
7133 .as_ref()
7134 .map(v2_local_asset_records)
7135 .transpose()?
7136 .unwrap_or_default();
7137 let mut content_merge = merge_v2_pulled_records(
7138 base,
7139 &remote,
7140 local,
7141 |file, _| (file.sha256.clone(), file.bytes),
7142 |file, _| (file.sha256.clone(), file.bytes),
7143 kept_home,
7144 );
7145 if let Some(selected) = take_remote {
7146 for path in selected {
7147 if let Some(position) = content_merge
7148 .conflicts
7149 .iter()
7150 .position(|conflict| conflict == path)
7151 {
7152 content_merge.conflicts.remove(position);
7153 content_merge.accept_remote.insert(path.clone());
7154 match remote.get(path) {
7155 Some(file) => {
7156 content_merge
7157 .records
7158 .insert(path.clone(), (file.sha256.clone(), file.bytes));
7159 }
7160 None => {
7161 content_merge.records.remove(path);
7162 }
7163 }
7164 } else if !content_merge.accept_remote.contains(path) {
7165 return Err(LinkError::InvalidPack {
7166 message: format!(
7167 "take-remote path `{path}` is no longer at its conflict coordinate"
7168 ),
7169 });
7170 }
7171 }
7172 }
7173 if !content_merge.conflicts.is_empty() {
7174 let mut conflicts = content_merge.conflicts.clone();
7175 conflicts.truncate(100);
7176 if let Some(store) = local_store.as_ref() {
7177 let (bundle, paths) = create_v2_conflict_bundle(
7178 cfg,
7179 store,
7180 &head,
7181 baseline.as_ref(),
7182 local,
7183 &remote,
7184 &conflicts,
7185 )?;
7186 return Err(LinkError::ConflictBundle { bundle, paths });
7187 }
7188 return Err(LinkError::Conflict { paths: conflicts });
7189 }
7190 let asset_merge = merge_v2_pulled_records(
7191 base_assets,
7192 &remote_assets,
7193 &local_assets,
7194 v2_asset_record,
7195 v2_asset_record,
7196 |_| false,
7197 );
7198 if !asset_merge.conflicts.is_empty() {
7199 let mut conflicts = asset_merge.conflicts.clone();
7200 conflicts.truncate(100);
7201 return Err(LinkError::Conflict { paths: conflicts });
7202 }
7203 let pointer = head.pointer.as_ref();
7204 let cache_transaction = pointer.map_or_else(
7205 || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
7206 |value| value.commit_hash.clone(),
7207 );
7208 let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
7209 let mut changed = match pointer {
7210 Some(pointer) => stage_v2_blobs(
7211 cfg,
7212 &head.brain_id,
7213 pointer,
7214 remote
7215 .iter()
7216 .filter(|(path, file)| {
7217 content_merge.accept_remote.contains(*path)
7218 && local.get(*path).map(|value| value.0.as_str())
7219 != Some(file.sha256.as_str())
7220 })
7221 .collect(),
7222 )?,
7223 None => Vec::new(),
7224 };
7225 let mut deleted = content_merge
7226 .accept_remote
7227 .iter()
7228 .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
7229 .cloned()
7230 .collect::<Vec<_>>();
7231 if local_assets != asset_merge.records {
7232 if asset_merge.records.is_empty() {
7233 deleted.push("assets.jsonl".to_string());
7234 } else {
7235 let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
7236 let sha256 = content_sha256(&bytes);
7237 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
7238 changed.push(V2StagedFile {
7239 path: "assets.jsonl".to_string(),
7240 source,
7241 sha256,
7242 bytes: bytes.len() as u64,
7243 });
7244 }
7245 }
7246 if let Some(pointer) = pointer {
7247 let mut pending_assets = Vec::new();
7248 for (path, asset) in &remote_assets {
7249 if asset.disposition != "hosted"
7250 || kept_home(path)
7251 || !asset_merge.accept_remote.contains(path)
7252 {
7253 continue;
7254 }
7255 if is_markdown_asset_path(path) {
7256 continue;
7259 }
7260 let already_current = local_store.as_ref().is_some_and(|store| {
7261 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7262 && store
7263 .read_bounded(Path::new(path), asset.bytes)
7264 .ok()
7265 .is_some_and(|bytes| {
7266 bytes.len() as u64 == asset.bytes
7267 && content_sha256(&bytes) == asset.blob_sha256
7268 })
7269 });
7270 if !already_current {
7271 pending_assets.push((path, asset));
7272 }
7273 }
7274 let mut window = Vec::new();
7275 let mut window_bytes = 0_u64;
7276 let flush = |window: &mut Vec<(&String, &V2BaselineAsset)>,
7277 window_bytes: &mut u64,
7278 changed: &mut Vec<V2StagedFile>|
7279 -> LinkResult<()> {
7280 changed.extend(stage_v2_asset_download_window(
7281 cfg,
7282 &head.brain_id,
7283 pointer,
7284 &cache_dir,
7285 window,
7286 )?);
7287 window.clear();
7288 *window_bytes = 0;
7289 Ok(())
7290 };
7291 for item @ (_, asset) in pending_assets {
7292 if !window.is_empty()
7293 && (window.len() == V2_DOWNLOAD_CAPABILITY_FILES
7294 || window_bytes.saturating_add(asset.bytes) > V2_DOWNLOAD_CAPABILITY_BYTES)
7295 {
7296 flush(&mut window, &mut window_bytes, &mut changed)?;
7297 }
7298 window.push(item);
7299 window_bytes = window_bytes.saturating_add(asset.bytes);
7300 if window_bytes >= V2_DOWNLOAD_CAPABILITY_BYTES {
7301 flush(&mut window, &mut window_bytes, &mut changed)?;
7302 }
7303 }
7304 flush(&mut window, &mut window_bytes, &mut changed)?;
7305 }
7306 for (path, prior) in base_assets {
7307 if remote_assets.contains_key(path)
7308 || kept_home(path)
7309 || !asset_merge.accept_remote.contains(path)
7310 {
7311 continue;
7312 }
7313 let unchanged = local_store.as_ref().is_some_and(|store| {
7314 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7315 && store
7316 .read_bounded(Path::new(path), prior.bytes)
7317 .ok()
7318 .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
7319 });
7320 if unchanged {
7321 deleted.push(path.clone());
7322 }
7323 }
7324 let extra_local = content_merge
7325 .records
7326 .keys()
7327 .filter(|path| !remote.contains_key(*path))
7328 .cloned()
7329 .collect::<Vec<_>>();
7330 if head.view_kind == "scoped" {
7331 for (path, bytes) in [
7332 ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
7333 (
7334 ".dbmd/view.json".to_string(),
7335 scoped_view_metadata(&head, remote.len())?,
7336 ),
7337 ] {
7338 let sha256 = content_sha256(&bytes);
7339 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
7340 changed.push(V2StagedFile {
7341 path,
7342 source,
7343 sha256,
7344 bytes: bytes.len() as u64,
7345 });
7346 }
7347 }
7348 let install_changed = !changed.is_empty() || !deleted.is_empty();
7349 install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
7350 let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
7351 let installed_store =
7352 Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
7353 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
7354 })?;
7355 let installed_local = if install_changed {
7356 let hint = baseline.as_ref().and_then(|state| {
7357 state
7358 .local_policy_digest
7359 .as_deref()
7360 .map(|digest| (digest, &state.scan_cache))
7361 });
7362 let mut scanned = v2_local_files_cached(&installed_store, hint)?;
7363 remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
7364 scanned
7365 } else {
7366 local_view
7367 .take()
7368 .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
7369 };
7370 if installed_local.riding != content_merge.records {
7371 return Err(LinkError::InvalidPack {
7372 message: "local content changed while installing the v2 pull".to_string(),
7373 });
7374 }
7375 let installed_assets = if install_changed {
7376 v2_local_asset_records(&installed_store)?
7377 } else {
7378 std::mem::take(&mut local_assets)
7379 };
7380 if installed_assets != asset_merge.records {
7381 return Err(LinkError::InvalidPack {
7382 message: "local assets changed while installing the v2 pull".to_string(),
7383 });
7384 }
7385 let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
7386 installed_local.policy.keeps_home(path)
7387 })
7388 || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
7389 let final_head = v2_verified_head(cfg, requested_brain)?
7390 .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
7391 if !same_v2_head(&head, &final_head) {
7392 return Err(LinkError::RemoteAdvancedDuringSync);
7393 }
7394 accept_v2_head(cfg, &final_head)?;
7395 save_v2_baseline(
7396 cfg,
7397 &head.brain_id,
7398 &dest,
7399 &v2_baseline_from_head(
7400 cfg,
7401 &head,
7402 remote.clone(),
7403 remote_assets.clone(),
7404 Some(&installed_local),
7405 baseline
7406 .as_ref()
7407 .and_then(|current| current.checkout_id.as_deref()),
7408 )?,
7409 )?;
7410 complete_v2_pull(&dest)?;
7411 Ok((local_dirty, installed_local, installed_assets))
7412 })();
7413 let (local_dirty, installed_local, installed_assets) = match finalized {
7414 Ok(value) => value,
7415 Err(error) => {
7416 if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
7417 return Err(LinkError::InvalidPack {
7418 message: format!("{error}; durable pull recovery also failed: {recovery}"),
7419 });
7420 }
7421 return Err(error);
7422 }
7423 };
7424 cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
7425 let report = PullReport {
7426 brain: head.brain_id.clone(),
7427 slug: requested_brain.to_string(),
7428 head_seq: pointer.map_or(0, |value| value.seq),
7429 files: remote.len() + remote_assets.len(),
7430 dest: dest.to_string_lossy().into_owned(),
7431 extra_local,
7432 sync_status: if local_dirty {
7433 "local_dirty_after_install".to_string()
7434 } else {
7435 "synced".to_string()
7436 },
7437 };
7438 Ok(V2PulledSnapshot {
7439 report,
7440 head,
7441 files: remote,
7442 assets: remote_assets,
7443 local: installed_local,
7444 local_assets: installed_assets,
7445 })
7446}
7447
7448fn v2_sync_pull(
7449 cfg: &HubConfig,
7450 requested_brain: &str,
7451 head: V2VerifiedHead,
7452 out: Option<&Path>,
7453) -> LinkResult<PullReport> {
7454 Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
7455}
7456
7457fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
7458 match remote {
7459 Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
7460 None => json!({ "kind": "absent" }),
7461 }
7462}
7463
7464fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
7465 match remote {
7466 Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
7467 None => json!({ "kind": "absent" }),
7468 }
7469}
7470
7471fn v2_content_withdrawal_operation(
7472 store: &Store,
7473 local_view: &V2LocalView,
7474 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7475 path: &str,
7476 reason: &str,
7477) -> LinkResult<Value> {
7478 if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
7479 || path == "DB.md"
7480 {
7481 return Err(LinkError::InvalidPack {
7482 message: format!("content withdrawal path `{path}` is not a record or source"),
7483 });
7484 }
7485 if !local_view.policy.keeps_home(path)
7486 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7487 {
7488 return Err(LinkError::InvalidPack {
7489 message: format!(
7490 "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7491 ),
7492 });
7493 }
7494 let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
7495 message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
7496 })?;
7497 verify_v2_upload_source(store, path, ¤t.sha256, current.bytes)?;
7498 Ok(json!({
7499 "op": "withdraw_from_hosting",
7500 "path": path,
7501 "expected": { "kind": "blob", "hash": current.sha256 },
7502 "reason": reason,
7503 }))
7504}
7505
7506fn v2_asset_withdrawal_operation(
7507 store: &Store,
7508 local_view: &V2LocalView,
7509 path: &str,
7510 local: &crate::AssetRecord,
7511 current: &V2BaselineAsset,
7512 reason: &str,
7513) -> LinkResult<Value> {
7514 if !local_view.policy.keeps_home(path)
7515 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7516 {
7517 return Err(LinkError::InvalidPack {
7518 message: format!(
7519 "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7520 ),
7521 });
7522 }
7523 verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
7524 if current.disposition != "hosted"
7525 || current.blob_sha256 != local.sha256
7526 || current.bytes != local.bytes
7527 || current.media_type != local.media_type
7528 {
7529 return Err(LinkError::InvalidPack {
7530 message: format!(
7531 "asset withdrawal path `{path}` must preserve the currently hosted blob identity, byte count, and media type"
7532 ),
7533 });
7534 }
7535 Ok(json!({
7536 "op": "asset_withdraw",
7537 "path": path,
7538 "expected": v2_asset_expected(Some(current)),
7539 "asset": v2_asset_value(local, "withheld"),
7540 "reason": reason,
7541 }))
7542}
7543
7544fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
7551 let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
7552 let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
7553 for (index, operation) in operations.iter().enumerate() {
7554 match operation.get("op").and_then(Value::as_str) {
7555 Some("delete") => {
7556 let Some(path) = operation.get("path").and_then(Value::as_str) else {
7557 continue;
7558 };
7559 let Some(hash) = operation
7560 .get("expected")
7561 .and_then(|value| value.get("hash"))
7562 .and_then(Value::as_str)
7563 else {
7564 continue;
7565 };
7566 if path.starts_with("sources/") {
7567 deletes
7568 .entry(hash.to_string())
7569 .or_default()
7570 .push((index, path.to_string()));
7571 }
7572 }
7573 Some("put" | "put_asset_content") => {
7574 let Some(path) = operation.get("path").and_then(Value::as_str) else {
7575 continue;
7576 };
7577 let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
7578 continue;
7579 };
7580 let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
7581 continue;
7582 };
7583 let destination_absent = operation
7584 .get("expected")
7585 .and_then(|value| value.get("kind"))
7586 .and_then(Value::as_str)
7587 == Some("absent");
7588 if path.starts_with("sources/") && destination_absent {
7589 puts.entry(hash.to_string()).or_default().push((
7590 index,
7591 path.to_string(),
7592 bytes,
7593 ));
7594 }
7595 }
7596 _ => {}
7597 }
7598 }
7599 let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
7600 let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
7601 for (hash, source) in deletes {
7602 let Some(destination) = puts.get(&hash) else {
7603 continue;
7604 };
7605 if source.len() != 1 || destination.len() != 1 {
7606 continue;
7607 }
7608 let (delete_index, from) = &source[0];
7609 let (put_index, to, bytes) = &destination[0];
7610 if from == to {
7611 continue;
7612 }
7613 rename_at.insert(
7614 *delete_index,
7615 json!({
7616 "op": "rename",
7617 "from": from,
7618 "to": to,
7619 "expected_from": { "kind": "blob", "hash": hash },
7620 "expected_to": { "kind": "absent" },
7621 "blob": hash,
7622 "bytes": bytes,
7623 }),
7624 );
7625 consumed_puts.insert(*put_index);
7626 }
7627 operations
7628 .into_iter()
7629 .enumerate()
7630 .filter_map(|(index, operation)| {
7631 if let Some(rename) = rename_at.remove(&index) {
7632 Some(rename)
7633 } else if consumed_puts.contains(&index) {
7634 None
7635 } else {
7636 Some(operation)
7637 }
7638 })
7639 .collect()
7640}
7641
7642fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
7643 json!({
7644 "blob_sha256": record.sha256,
7645 "bytes": record.bytes,
7646 "media_type": record.media_type,
7647 "wrappers": record.wrappers,
7648 "required": record.required,
7649 "disposition": disposition,
7650 })
7651}
7652
7653fn apply_generated_v2_operations(
7657 operations: &[Value],
7658 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
7659 candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
7660 candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
7661) -> LinkResult<bool> {
7662 let mut asset_changed = false;
7663 for operation in operations {
7664 match operation.get("op").and_then(Value::as_str) {
7665 Some("put" | "put_asset_content") => {
7666 let path = operation
7667 .get("path")
7668 .and_then(Value::as_str)
7669 .ok_or_else(|| invalid_feed("v2 put has no path"))?;
7670 let sha256 = operation
7671 .get("blob")
7672 .and_then(Value::as_str)
7673 .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
7674 let bytes = operation
7675 .get("bytes")
7676 .and_then(Value::as_u64)
7677 .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
7678 candidate.insert(
7679 path.to_string(),
7680 V2BaselineFile {
7681 sha256: sha256.to_string(),
7682 bytes,
7683 proof: None,
7684 },
7685 );
7686 }
7687 Some("rename") => {
7688 let from = operation
7689 .get("from")
7690 .and_then(Value::as_str)
7691 .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
7692 let to = operation
7693 .get("to")
7694 .and_then(Value::as_str)
7695 .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
7696 let sha256 = operation
7697 .get("blob")
7698 .and_then(Value::as_str)
7699 .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
7700 let bytes = operation
7701 .get("bytes")
7702 .and_then(Value::as_u64)
7703 .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
7704 let expected_from = operation
7705 .get("expected_from")
7706 .and_then(|expected| expected.get("hash"))
7707 .and_then(Value::as_str);
7708 let expected_to_absent = operation
7709 .get("expected_to")
7710 .and_then(|expected| expected.get("kind"))
7711 .and_then(Value::as_str)
7712 == Some("absent");
7713 if from == to
7714 || !from.starts_with("sources/")
7715 || !to.starts_with("sources/")
7716 || expected_from != Some(sha256)
7717 || !expected_to_absent
7718 || candidate.contains_key(to)
7719 {
7720 return Err(invalid_feed("generated v2 source rename is malformed"));
7721 }
7722 let source = candidate
7723 .remove(from)
7724 .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
7725 if source.sha256 != sha256 || source.bytes != bytes {
7726 return Err(invalid_feed(
7727 "v2 rename source differs from its exact-byte claim",
7728 ));
7729 }
7730 candidate.insert(
7731 to.to_string(),
7732 V2BaselineFile {
7733 sha256: sha256.to_string(),
7734 bytes,
7735 proof: None,
7736 },
7737 );
7738 }
7739 Some("delete" | "withdraw_from_hosting") => {
7740 let path = operation
7741 .get("path")
7742 .and_then(Value::as_str)
7743 .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7744 candidate.remove(path);
7745 }
7746 Some("asset_delete") => {
7747 let path = operation
7748 .get("path")
7749 .and_then(Value::as_str)
7750 .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7751 candidate_assets.remove(path);
7752 asset_changed = true;
7753 }
7754 Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7755 let path = operation
7756 .get("path")
7757 .and_then(Value::as_str)
7758 .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7759 let record = local_assets
7760 .get(path)
7761 .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7762 let disposition =
7763 if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7764 "withheld"
7765 } else {
7766 operation
7767 .get("asset")
7768 .and_then(|asset| asset.get("disposition"))
7769 .and_then(Value::as_str)
7770 .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7771 };
7772 candidate_assets.insert(
7773 path.to_string(),
7774 V2BaselineAsset {
7775 blob_sha256: record.sha256.clone(),
7776 bytes: record.bytes,
7777 media_type: record.media_type.clone(),
7778 wrappers: record.wrappers.clone(),
7779 required: record.required,
7780 disposition: disposition.to_string(),
7781 leaf_hash: String::new(),
7784 },
7785 );
7786 asset_changed = true;
7787 }
7788 _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7789 }
7790 }
7791 Ok(asset_changed)
7792}
7793
7794fn v2_riding_matches_remote(
7795 local: &std::collections::BTreeMap<String, (String, u64)>,
7796 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7797 keeps_home: impl Fn(&str) -> bool,
7798) -> bool {
7799 remote.iter().all(|(path, file)| {
7800 keeps_home(path)
7801 || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7802 }) && local.iter().all(|(path, (hash, _))| {
7803 remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7804 })
7805}
7806
7807fn v2_initial_content_conflicts(
7808 local: &std::collections::BTreeMap<String, (String, u64)>,
7809 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7810 resolving: bool,
7811) -> Vec<String> {
7812 if resolving {
7813 return Vec::new();
7821 }
7822 remote
7823 .iter()
7824 .filter(|(path, file)| {
7825 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7826 })
7827 .map(|(path, _)| path.clone())
7828 .collect()
7829}
7830
7831fn v2_resolution_allows_path(
7832 resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7833 path: &str,
7834 remote_present: bool,
7835) -> bool {
7836 resolution.is_none_or(|allowed| allowed.contains_key(path) || !remote_present)
7837}
7838
7839fn v2_resolution_allows_asset(
7840 resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7841 base: Option<&V2BaselineAsset>,
7842 remote: Option<&V2BaselineAsset>,
7843 local: Option<&crate::AssetRecord>,
7844) -> bool {
7845 let Some(allowed) = resolution else {
7846 return false;
7847 };
7848 let wrappers = base
7849 .into_iter()
7850 .flat_map(|asset| asset.wrappers.iter())
7851 .chain(remote.into_iter().flat_map(|asset| asset.wrappers.iter()))
7852 .chain(local.into_iter().flat_map(|asset| asset.wrappers.iter()))
7853 .collect::<BTreeSet<_>>();
7854 !wrappers.is_empty()
7855 && wrappers
7856 .iter()
7857 .all(|wrapper| allowed.contains_key(*wrapper))
7858}
7859
7860#[derive(Debug, Clone)]
7861struct V2ResolutionOverride {
7862 expected_remote: Option<String>,
7863 selected_local: Option<String>,
7864}
7865
7866#[derive(Debug, Clone)]
7867struct V2UploadSource {
7868 path: String,
7869 bytes: u64,
7870}
7871
7872struct V2SyncPushOptions<'a> {
7873 resume_local_policy: bool,
7874 bulk_confirmation: Option<&'a V2BulkConfirmation>,
7875 resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7876 pulled: Option<V2PulledSnapshot>,
7877 withdrawal_paths: &'a [String],
7878 withdrawal_reason: Option<&'a str>,
7879 allow_contract_phase: bool,
7884}
7885
7886fn verify_v2_upload_source(
7887 store: &Store,
7888 path: &str,
7889 sha256: &str,
7890 expected_bytes: u64,
7891) -> LinkResult<()> {
7892 let file = store.open_regular(Path::new(path))?;
7893 if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7894 return Err(LinkError::InvalidPack {
7895 message: format!("local path `{path}` changed during sync planning"),
7896 });
7897 }
7898 Ok(())
7899}
7900
7901struct V2PendingUpload<'a> {
7904 url: String,
7905 headers: Value,
7906 sha256: String,
7907 source: &'a V2UploadSource,
7908}
7909
7910const V2_UPLOAD_CONCURRENCY: usize = 16;
7917
7918fn upload_v2_batch_concurrently(
7922 cfg: &HubConfig,
7923 store: &Store,
7924 pending: &[V2PendingUpload<'_>],
7925) -> LinkResult<()> {
7926 if pending.is_empty() {
7927 return Ok(());
7928 }
7929 let urls = pending
7930 .iter()
7931 .map(|task| task.url.as_str())
7932 .collect::<Vec<_>>();
7933 let shared = shared_staging_agent(cfg, &urls);
7934 if pending.len() == 1 {
7935 let task = &pending[0];
7936 put_presigned_source(
7937 cfg,
7938 &task.url,
7939 &task.headers,
7940 store,
7941 task.source,
7942 shared.as_ref(),
7943 )?;
7944 return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7945 }
7946 let next = std::sync::atomic::AtomicUsize::new(0);
7947 let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7948 let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7949 std::thread::scope(|scope| {
7950 for _ in 0..workers {
7951 scope.spawn(|| loop {
7952 if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7953 return;
7954 }
7955 let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7956 let Some(task) = pending.get(index) else {
7957 return;
7958 };
7959 let outcome = put_presigned_source(
7960 cfg,
7961 &task.url,
7962 &task.headers,
7963 store,
7964 task.source,
7965 shared.as_ref(),
7966 )
7967 .and_then(|()| {
7968 verify_v2_upload_source(
7969 store,
7970 &task.source.path,
7971 &task.sha256,
7972 task.source.bytes,
7973 )
7974 });
7975 if let Err(error) = outcome {
7976 if let Ok(mut guard) = failure.lock() {
7977 guard.get_or_insert(error);
7978 }
7979 return;
7980 }
7981 });
7982 }
7983 });
7984 match failure.into_inner() {
7985 Ok(Some(error)) => Err(error),
7986 Ok(None) => Ok(()),
7987 Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7988 }
7989}
7990
7991fn put_presigned_source(
7992 cfg: &HubConfig,
7993 raw: &str,
7994 headers: &Value,
7995 store: &Store,
7996 source: &V2UploadSource,
7997 shared: Option<&ureq::Agent>,
7998) -> LinkResult<()> {
7999 put_presigned_source_with_budget(
8000 cfg,
8001 raw,
8002 headers,
8003 store,
8004 source,
8005 shared,
8006 std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
8007 )
8008}
8009
8010fn put_presigned_source_with_budget(
8011 cfg: &HubConfig,
8012 raw: &str,
8013 headers: &Value,
8014 store: &Store,
8015 source: &V2UploadSource,
8016 shared: Option<&ureq::Agent>,
8017 total_budget: std::time::Duration,
8018) -> LinkResult<()> {
8019 let owned = match shared {
8022 Some(_) => {
8023 checked_presigned_url(cfg, raw)?;
8024 None
8025 }
8026 None => Some(presigned_agent(cfg, raw)?),
8027 };
8028 let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
8029 let deadline = std::time::Instant::now()
8030 .checked_add(total_budget)
8031 .ok_or_else(upload_deadline_error)?;
8032 let mut attempt = 0;
8033 let result = loop {
8034 let file = store.open_regular(Path::new(&source.path))?;
8035 if file.metadata()?.len() != source.bytes {
8036 return Err(LinkError::InvalidPack {
8037 message: format!("local path `{}` changed before upload", source.path),
8038 });
8039 }
8040 let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
8045 let mut has_content_length = false;
8046 if let Some(map) = headers.as_object() {
8047 for (name, value) in map {
8048 if let Some(value) = value.as_str() {
8049 has_content_length |= name.eq_ignore_ascii_case("content-length");
8050 req = req.set(name, value);
8051 }
8052 }
8053 }
8054 if !has_content_length {
8055 req = req.set("Content-Length", &source.bytes.to_string());
8056 }
8057 match req.send(file) {
8058 Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
8064 attempt += 1;
8065 }
8066 Err(ureq::Error::Status(status, _))
8072 if status != 412
8073 && is_retryable_upload_status(status)
8074 && wait_for_upload_retry(deadline, attempt) =>
8075 {
8076 attempt += 1;
8077 }
8078 result => break result,
8079 }
8080 };
8081 match result {
8082 Ok(response) if (200..300).contains(&response.status()) => {
8083 drain_presigned_response(response);
8084 Ok(())
8085 }
8086 Ok(response) => {
8087 let status = response.status();
8092 let detail = response
8093 .into_string()
8094 .ok()
8095 .map(|body| body.chars().take(400).collect::<String>())
8096 .filter(|body| !body.trim().is_empty());
8097 Err(LinkError::Http {
8098 what: "v2 changed-byte upload",
8099 status,
8100 message: match detail {
8101 Some(body) => format!(
8102 "object store rejected the upload of `{}`: {}",
8103 source.path,
8104 body.replace('\n', " ")
8105 ),
8106 None => format!("object store rejected the upload of `{}`", source.path),
8107 },
8108 code: None,
8109 details: None,
8110 })
8111 }
8112 Err(error) => match error {
8113 ureq::Error::Status(412, _) => Ok(()),
8114 ureq::Error::Status(_, response) => {
8115 let status = response.status();
8116 let detail = response
8117 .into_string()
8118 .ok()
8119 .map(|body| body.chars().take(400).collect::<String>())
8120 .filter(|body| !body.trim().is_empty());
8121 Err(LinkError::Http {
8122 what: "v2 changed-byte upload",
8123 status,
8124 message: match detail {
8125 Some(body) => format!(
8126 "object store rejected the upload of `{}`: {}",
8127 source.path,
8128 body.replace('\n', " ")
8129 ),
8130 None => {
8131 format!("object store rejected the upload of `{}`", source.path)
8132 }
8133 },
8134 code: None,
8135 details: None,
8136 })
8137 }
8138 ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
8139 },
8140 }
8141}
8142
8143fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
8147 if body.get("operations").is_some() {
8148 return body.clone();
8149 }
8150 let mut value = body.clone();
8151 if let Some(map) = value.as_object_mut() {
8152 map.remove("staged_change");
8153 map.insert("operations".to_string(), Value::Array(operations.to_vec()));
8154 }
8155 value
8156}
8157
8158fn reserve_upload_window(
8162 cfg: &HubConfig,
8163 path: &str,
8164 body: &Value,
8165 what: &'static str,
8166) -> LinkResult<Value> {
8167 let mut attempt = 0;
8168 loop {
8169 let pause = |attempt: usize| {
8170 std::thread::sleep(std::time::Duration::from_millis(
8171 RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
8172 ));
8173 };
8174 match request(cfg, "POST", path, Some(body), Auth::Required) {
8175 Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
8180 pause(attempt);
8181 attempt += 1;
8182 }
8183 Err(error) => return Err(error),
8184 Ok(response) => {
8185 if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
8186 pause(attempt);
8187 attempt += 1;
8188 continue;
8189 }
8190 return ensure_ok(response, what);
8191 }
8192 }
8193 }
8194}
8195
8196fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
8200 let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
8201 .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
8202 if bytes.len() > MAX_STAGED_CHANGE_BYTES {
8203 return Err(LinkError::PushTooLarge {
8204 detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
8205 });
8206 }
8207 Ok(bytes)
8208}
8209
8210fn stage_v2_change(
8220 cfg: &HubConfig,
8221 requested_brain: &str,
8222 operations: &[Value],
8223 blobs: Value,
8224) -> LinkResult<Value> {
8225 let bytes = v2_change_manifest(operations, blobs)?;
8226 let sha256 = content_sha256(&bytes);
8227 let reserved = reserve_upload_window(
8228 cfg,
8229 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8230 &json!({
8231 "blobs": [{
8232 "sha256": sha256,
8233 "bytes": bytes.len(),
8234 "kind": "staged_change",
8235 }],
8236 }),
8237 "stage the v2 change",
8238 )?;
8239 let items = reserved
8240 .get("uploads")
8241 .and_then(Value::as_array)
8242 .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
8243 let [item] = items.as_slice() else {
8244 return Err(invalid_feed(
8245 "v2 change staging response changed the requested set",
8246 ));
8247 };
8248 let reservation_id = item
8249 .get("reservation_id")
8250 .and_then(Value::as_str)
8251 .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
8252 if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
8253 || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
8254 || !crate::ulid::is_ulid(reservation_id)
8255 {
8256 return Err(invalid_feed("v2 change staging item is inconsistent"));
8257 }
8258 match item.get("status").and_then(Value::as_str) {
8259 Some("upload") => put_presigned(
8260 cfg,
8261 item.get("url")
8262 .and_then(Value::as_str)
8263 .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
8264 item.get("headers").unwrap_or(&Value::Null),
8265 &bytes,
8266 )?,
8267 Some("already_present") => {}
8268 _ => return Err(invalid_feed("v2 change staging has an unknown status")),
8269 }
8270 Ok(json!({
8271 "sha256": sha256,
8272 "bytes": bytes.len(),
8273 "reservation_id": reservation_id,
8274 }))
8275}
8276
8277fn stage_oversized_v2_change(
8281 cfg: &HubConfig,
8282 requested_brain: &str,
8283 operations: &[Value],
8284 body: &mut Value,
8285) -> LinkResult<()> {
8286 if body.to_string().len() <= MAX_PUSH_BYTES {
8287 return Ok(());
8288 }
8289 let staged = stage_v2_change(
8290 cfg,
8291 requested_brain,
8292 operations,
8293 body.get("blobs")
8294 .cloned()
8295 .unwrap_or(Value::Array(Vec::new())),
8296 )?;
8297 let map = body
8298 .as_object_mut()
8299 .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
8300 map.remove("operations");
8301 map.remove("blobs");
8302 map.insert("staged_change".to_string(), staged);
8303 Ok(())
8304}
8305
8306fn v2_sync_push(
8307 cfg: &HubConfig,
8308 requested_brain: &str,
8309 store: &Store,
8310 head: V2VerifiedHead,
8311 options: V2SyncPushOptions<'_>,
8312) -> LinkResult<Value> {
8313 let V2SyncPushOptions {
8314 resume_local_policy,
8315 bulk_confirmation,
8316 resolution,
8317 pulled,
8318 withdrawal_paths,
8319 withdrawal_reason,
8320 allow_contract_phase,
8321 } = options;
8322 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
8323 let head = v2_verified_head(cfg, requested_brain)?
8324 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
8325 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
8326 ensure_v2_view_compatible(&head, baseline.as_ref())?;
8327 let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
8328 match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
8329 Some(snapshot) => (
8330 snapshot.files,
8331 snapshot.assets,
8332 Some(snapshot.local),
8333 Some(snapshot.local_assets),
8334 ),
8335 None => match baseline
8336 .as_ref()
8337 .filter(|state| v2_baseline_matches_head(&head, state))
8338 {
8339 Some(state) => (state.files.clone(), state.assets.clone(), None, None),
8340 None => (
8341 files_for_v2_view(
8342 &head,
8343 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
8344 ),
8345 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
8346 None,
8347 None,
8348 ),
8349 },
8350 };
8351 if head.view_kind == "scoped" && baseline.is_none() {
8352 return Err(LinkError::ScopedViewChanged);
8353 }
8354 let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
8355 let local = &local_view.riding;
8356 let local_assets = match carried_local_assets {
8357 Some(assets) => assets,
8358 None => v2_local_asset_records(store)?,
8359 };
8360 if withdrawal_paths.len() > MAX_PUSH_FILES {
8361 return Err(LinkError::PushTooLarge {
8362 detail: "too many explicit withdrawal paths".to_string(),
8363 });
8364 }
8365 let withdrawal_reason = if withdrawal_paths.is_empty() {
8366 None
8367 } else {
8368 let reason = withdrawal_reason
8369 .map(str::trim)
8370 .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
8371 .ok_or_else(|| LinkError::InvalidPack {
8372 message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
8373 })?;
8374 Some(reason)
8375 };
8376 let mut withdrawals = withdrawal_paths
8377 .iter()
8378 .map(|path| {
8379 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
8380 path: error.to_string(),
8381 })
8382 })
8383 .collect::<LinkResult<Vec<_>>>()?;
8384 withdrawals.sort();
8385 withdrawals.dedup();
8386 if withdrawals.len() != withdrawal_paths.len() {
8387 return Err(LinkError::InvalidPack {
8388 message: "explicit withdrawal paths must be unique".to_string(),
8389 });
8390 }
8391 let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
8392 let mut consumed_withdrawals = BTreeSet::new();
8393 if let Some(previous) = baseline.as_ref() {
8394 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
8395 && !resume_local_policy
8396 {
8397 let mut newly_eligible = previous
8398 .local_eligibility
8399 .iter()
8400 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
8401 .map(|(path, _)| path.clone())
8402 .collect::<Vec<_>>();
8403 if !newly_eligible.is_empty() {
8404 newly_eligible.truncate(100);
8405 return Err(LinkError::LocalPolicyTransition {
8406 paths: newly_eligible,
8407 });
8408 }
8409 }
8410 }
8411 if baseline
8417 .as_ref()
8418 .is_some_and(|state| !v2_baseline_matches_head(&head, state))
8419 && resolution.is_none()
8420 && withdrawal_paths.is_empty()
8421 && v2_riding_matches_remote(local, &remote, |path| local_view.policy.keeps_home(path))
8422 && v2_asset_records_match_remote(&local_assets, &remote_assets)
8423 {
8424 let final_head = v2_verified_head(cfg, requested_brain)?
8425 .ok_or_else(|| invalid_feed("v2 head disappeared during baseline recovery"))?;
8426 if !same_v2_head(&head, &final_head) {
8427 return Err(LinkError::RemoteAdvancedDuringSync);
8428 }
8429 let mut final_local = v2_local_files_cached(
8430 store,
8431 Some((&local_view.policy.digest, &local_view.scan_cache)),
8432 )?;
8433 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8434 let final_assets = v2_local_asset_records(store)?;
8435 if final_local.riding != local_view.riding || final_assets != local_assets {
8436 return Err(LinkError::RemoteAdvancedDuringSync);
8437 }
8438 let checkout_pseudonym = v2_checkout_id(
8439 baseline
8440 .as_ref()
8441 .and_then(|current| current.checkout_id.as_deref()),
8442 )?;
8443 let next = v2_baseline_from_head(
8444 cfg,
8445 &head,
8446 remote,
8447 remote_assets,
8448 Some(&final_local),
8449 Some(&checkout_pseudonym),
8450 )?;
8451 let split_count = next.remote_copy_remains.len();
8452 accept_v2_head(cfg, &final_head)?;
8453 refresh_scoped_view_marker(store, &head, next.files.len())?;
8454 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8455 return Ok(json!({
8456 "v": 2,
8457 "outcome": "no_change",
8458 "sync_status": "synced",
8459 "baseline_recovered": true,
8460 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8461 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8462 "local_policy": {
8463 "remote_copy_remains": split_count,
8464 },
8465 }));
8466 }
8467 let base = match baseline.as_ref() {
8468 Some(state) => &state.files,
8469 None if remote.is_empty() => &remote,
8470 None => {
8471 let mut conflicts = v2_initial_content_conflicts(local, &remote, resolution.is_some());
8472 if !conflicts.is_empty() {
8473 conflicts.truncate(100);
8474 let (bundle, paths) =
8475 create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
8476 return Err(LinkError::ConflictBundle { bundle, paths });
8477 }
8478 &remote
8479 }
8480 };
8481 let all_paths = base
8482 .keys()
8483 .chain(remote.keys())
8484 .chain(local.keys())
8485 .cloned()
8486 .collect::<std::collections::BTreeSet<_>>();
8487 let mut conflicts = Vec::new();
8488 let mut operations = Vec::new();
8489 let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
8490 for path in all_paths {
8491 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
8492 let remote_file = remote.get(&path);
8493 let remote_hash = remote_file.map(|file| file.sha256.as_str());
8494 let local_file = local.get(&path);
8495 let local_hash = local_file.map(|file| file.0.as_str());
8496 if local_hash == remote_hash {
8501 continue;
8502 }
8503 if local_hash == base_hash {
8504 continue;
8505 }
8506 if !v2_resolution_allows_path(resolution, &path, remote_file.is_some()) {
8507 continue;
8508 }
8509 if local_view.policy.keeps_home(&path) {
8510 continue;
8513 }
8514 if remote_hash != base_hash && local_hash != remote_hash {
8515 let explicitly_resolved = resolution
8516 .and_then(|allowed| allowed.get(&path))
8517 .is_some_and(|selected| {
8518 selected.expected_remote.as_deref() == remote_hash
8519 && selected.selected_local.as_deref() == local_hash
8520 });
8521 if !explicitly_resolved {
8522 conflicts.push(path);
8523 continue;
8524 }
8525 }
8526 match local_file {
8527 Some((sha256, byte_count)) => {
8528 verify_v2_upload_source(store, &path, sha256, *byte_count)?;
8529 operations.push(json!({
8530 "op": v2_content_put_operation_kind(&path, &local_assets),
8531 "path": path,
8532 "expected": v2_expected(remote_file),
8533 "blob": sha256,
8534 "bytes": byte_count,
8535 }));
8536 upload_sources
8537 .entry(sha256.clone())
8538 .or_insert_with(|| V2UploadSource {
8539 path: path.clone(),
8540 bytes: *byte_count,
8541 });
8542 }
8543 None => {
8544 let Some(current) = remote_file else {
8545 continue;
8546 };
8547 operations.push(json!({
8548 "op": "delete",
8549 "path": path,
8550 "expected": { "kind": "blob", "hash": current.sha256 },
8551 }));
8552 }
8553 }
8554 }
8555 operations = infer_exact_source_promotions(operations);
8556 for path in &withdrawals {
8557 if !v2_withdrawal_includes_content(path, &local_assets) {
8558 continue;
8559 }
8560 operations.push(v2_content_withdrawal_operation(
8561 store,
8562 &local_view,
8563 &remote,
8564 path,
8565 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8566 )?);
8567 consumed_withdrawals.insert(path.clone());
8568 }
8569 if !conflicts.is_empty() {
8570 conflicts.truncate(100);
8571 let (bundle, paths) = create_v2_conflict_bundle(
8572 cfg,
8573 store,
8574 &head,
8575 baseline.as_ref(),
8576 local,
8577 &remote,
8578 &conflicts,
8579 )?;
8580 return Err(LinkError::ConflictBundle { bundle, paths });
8581 }
8582 let base_assets = match baseline.as_ref() {
8583 Some(state) => &state.assets,
8584 None if remote_assets.is_empty() => &remote_assets,
8585 None => {
8586 let mismatched = remote_assets
8587 .keys()
8588 .chain(local_assets.keys())
8589 .collect::<BTreeSet<_>>()
8590 .into_iter()
8591 .filter(|path| {
8592 remote_assets
8593 .get(*path)
8594 .map(|asset| v2_asset_record(asset, path))
8595 .as_ref()
8596 != local_assets.get(*path)
8597 })
8598 .collect::<Vec<_>>();
8599 let resolution_covers_all = !mismatched.is_empty()
8600 && mismatched.iter().all(|path| {
8601 v2_resolution_allows_asset(
8602 resolution,
8603 None,
8604 remote_assets.get(*path),
8605 local_assets.get(*path),
8606 )
8607 });
8608 if !mismatched.is_empty() && !resolution_covers_all {
8609 return Err(LinkError::Conflict {
8610 paths: vec!["assets.jsonl".to_string()],
8611 });
8612 }
8613 &remote_assets
8614 }
8615 };
8616 let asset_paths = base_assets
8617 .keys()
8618 .chain(remote_assets.keys())
8619 .chain(local_assets.keys())
8620 .cloned()
8621 .collect::<std::collections::BTreeSet<_>>();
8622 let mut asset_policy_transitions = Vec::new();
8623 let mut asset_withdrawal_transitions = Vec::new();
8624 for path in asset_paths {
8625 let base_record = base_assets
8626 .get(&path)
8627 .map(|asset| v2_asset_record(asset, &path));
8628 let remote = remote_assets.get(&path);
8629 let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
8630 let local_record = local_assets.get(&path);
8631 if withdrawal_set.contains(&path) {
8632 let record = local_record.ok_or_else(|| LinkError::InvalidPack {
8633 message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
8634 })?;
8635 let current = remote.ok_or_else(|| LinkError::InvalidPack {
8636 message: format!(
8637 "asset withdrawal path `{path}` has no readable hosted coordinate"
8638 ),
8639 })?;
8640 operations.push(v2_asset_withdrawal_operation(
8641 store,
8642 &local_view,
8643 &path,
8644 record,
8645 current,
8646 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8647 )?);
8648 consumed_withdrawals.insert(path.clone());
8649 continue;
8650 }
8651 let mut raw_present = false;
8652 let mut disposition = "withheld";
8653 let mut resumes_hosting = false;
8654 if let Some(record) = local_record {
8655 crate::linkmd_v2::normalize_path(&record.path)
8656 .map_err(|error| invalid_feed(error.to_string()))?;
8657 let kept_home = local_view.policy.keeps_home(&path);
8658 raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
8659 disposition = if kept_home || !raw_present {
8660 "withheld"
8661 } else {
8662 "hosted"
8663 };
8664 let inherits_withheld_absence = v2_asset_inherits_withheld_absence(
8665 base_assets.get(&path),
8666 base_record.as_ref(),
8667 local_record,
8668 raw_present,
8669 );
8670 if !raw_present && record.required && !kept_home && !inherits_withheld_absence {
8671 return Err(LinkError::InvalidPack {
8672 message: format!("required asset {path} is missing"),
8673 });
8674 }
8675 resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
8676 if remote.is_some_and(|asset| asset.disposition == "hosted")
8677 && disposition == "withheld"
8678 {
8679 asset_withdrawal_transitions.push(path.clone());
8680 continue;
8681 }
8682 }
8683 if local_record == base_record.as_ref() && !resumes_hosting {
8684 continue;
8685 }
8686 if remote_record != base_record
8687 && local_record != remote_record.as_ref()
8688 && !v2_resolution_allows_asset(resolution, base_assets.get(&path), remote, local_record)
8689 {
8690 conflicts.push(path);
8691 continue;
8692 }
8693 let Some(record) = local_record else {
8694 if let Some(remote) = remote {
8695 operations.push(json!({
8696 "op": "asset_delete",
8697 "path": path,
8698 "expected": v2_asset_expected(Some(remote)),
8699 }));
8700 }
8701 continue;
8702 };
8703 let raw = if raw_present {
8704 verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
8705 Some(())
8706 } else {
8707 None
8708 };
8709 let op = if resumes_hosting {
8710 if !resume_local_policy {
8711 asset_policy_transitions.push(path);
8712 continue;
8713 }
8714 "asset_resume"
8715 } else {
8716 "asset_put"
8717 };
8718 operations.push(json!({
8719 "op": op,
8720 "path": path,
8721 "expected": v2_asset_expected(remote),
8722 "asset": v2_asset_value(record, disposition),
8723 }));
8724 if disposition == "hosted" {
8725 raw.expect("hosted asset was checked present");
8726 upload_sources
8727 .entry(record.sha256.clone())
8728 .or_insert_with(|| V2UploadSource {
8729 path: path.clone(),
8730 bytes: record.bytes,
8731 });
8732 }
8733 }
8734 if consumed_withdrawals != withdrawal_set {
8735 let missing = withdrawal_set
8736 .difference(&consumed_withdrawals)
8737 .next()
8738 .expect("different withdrawal sets have one member");
8739 return Err(LinkError::InvalidPack {
8740 message: format!(
8741 "withdrawal path `{missing}` is not a readable content or asset coordinate"
8742 ),
8743 });
8744 }
8745 if !conflicts.is_empty() {
8746 conflicts.truncate(100);
8747 return Err(LinkError::Conflict { paths: conflicts });
8748 }
8749 if !asset_policy_transitions.is_empty() {
8750 asset_policy_transitions.truncate(100);
8751 return Err(LinkError::LocalPolicyTransition {
8752 paths: asset_policy_transitions,
8753 });
8754 }
8755 if !asset_withdrawal_transitions.is_empty() {
8756 asset_withdrawal_transitions.truncate(100);
8757 return Err(LinkError::AssetWithdrawalRequired {
8758 paths: asset_withdrawal_transitions,
8759 });
8760 }
8761 let contract_phase = operations.len() > 1
8762 && operations.iter().any(|operation| {
8763 operation.get("path").and_then(Value::as_str) == Some("DB.md")
8764 && !operation
8765 .get("op")
8766 .and_then(Value::as_str)
8767 .is_some_and(|kind| kind.starts_with("asset_"))
8768 });
8769 if contract_phase {
8770 if !allow_contract_phase {
8771 return Err(LinkError::RemoteAdvancedDuringSync);
8772 }
8773 operations.retain(|operation| {
8774 operation.get("path").and_then(Value::as_str) == Some("DB.md")
8775 && !operation
8776 .get("op")
8777 .and_then(Value::as_str)
8778 .is_some_and(|kind| kind.starts_with("asset_"))
8779 });
8780 let contract_blobs = operations
8781 .iter()
8782 .filter_map(|operation| operation.get("blob").and_then(Value::as_str))
8783 .collect::<BTreeSet<_>>();
8784 upload_sources.retain(|sha256, _| contract_blobs.contains(sha256.as_str()));
8785 }
8786 let touched_sources = operations
8787 .iter()
8788 .filter_map(
8789 |operation| match operation.get("op").and_then(Value::as_str) {
8790 Some("put" | "put_asset_content" | "restore") => {
8791 operation.get("path").and_then(Value::as_str)
8792 }
8793 Some("rename") => operation.get("to").and_then(Value::as_str),
8794 _ => None,
8795 },
8796 )
8797 .collect::<std::collections::BTreeSet<_>>();
8798 let withheld_links = local_view
8799 .withheld_links
8800 .iter()
8801 .filter(|link| touched_sources.contains(link.source.as_str()))
8802 .collect::<Vec<_>>();
8803 let checkout_pseudonym = v2_checkout_id(
8804 baseline
8805 .as_ref()
8806 .and_then(|current| current.checkout_id.as_deref()),
8807 )?;
8808 let checkout_id = if withheld_links.is_empty() {
8809 None
8810 } else {
8811 Some(checkout_pseudonym.clone())
8812 };
8813 if operations.is_empty() {
8814 let final_head = v2_verified_head(cfg, requested_brain)?
8815 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
8816 if !same_v2_head(&head, &final_head) {
8817 return Err(LinkError::RemoteAdvancedDuringSync);
8818 }
8819 let mut final_local = v2_local_files_cached(
8820 store,
8821 Some((&local_view.policy.digest, &local_view.scan_cache)),
8822 )?;
8823 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8824 let final_assets = v2_local_asset_records(store)?;
8825 let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
8826 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
8827 final_local.policy.keeps_home(path)
8828 }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
8829 let next = v2_baseline_from_head(
8830 cfg,
8831 &head,
8832 remote,
8833 remote_assets,
8834 Some(&final_local),
8835 Some(&checkout_pseudonym),
8836 )?;
8837 let split_count = next.remote_copy_remains.len();
8838 accept_v2_head(cfg, &final_head)?;
8839 if !remote_ahead {
8840 refresh_scoped_view_marker(store, &head, next.files.len())?;
8841 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8842 }
8843 return Ok(json!({
8844 "v": 2,
8845 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
8846 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
8847 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8848 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8849 "local_policy": {
8850 "remote_copy_remains": split_count,
8851 },
8852 }));
8853 }
8854 let includes_contract = operations
8855 .iter()
8856 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
8857 let rebase = if head.pointer.is_none() || includes_contract {
8858 "strict"
8859 } else {
8860 "disjoint"
8861 };
8862 let base_value = head.pointer.as_ref().map(|pointer| {
8863 json!({
8864 "seq": pointer.seq,
8865 "commit_hash": pointer.commit_hash,
8866 "content_root": pointer.content_root,
8867 "asset_root": pointer.asset_root,
8868 })
8869 });
8870 let entropy = format!(
8874 "{}\0{}\0{}\0{}\0{}\0{}",
8875 normalized_origin(&cfg.hub)?,
8876 head.brain_id,
8877 serde_json::to_string(&base_value).unwrap_or_default(),
8878 serde_json::to_string(&operations).unwrap_or_default(),
8879 serde_json::to_string(&withheld_links).unwrap_or_default(),
8880 checkout_id.as_deref().unwrap_or("")
8881 );
8882 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
8883 let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
8884 total
8885 .checked_add(source.bytes)
8886 .ok_or_else(|| LinkError::PushTooLarge {
8887 detail: "v2 changed-byte total overflow".to_string(),
8888 })
8889 })?;
8890 let inline = changed_bytes <= 3 * 1024 * 1024;
8891 let inline_blobs = if inline {
8892 upload_sources
8893 .iter()
8894 .map(|(sha256, source)| {
8895 let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
8896 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
8897 return Err(LinkError::InvalidPack {
8898 message: format!("local path `{}` changed before upload", source.path),
8899 });
8900 }
8901 Ok(json!({
8902 "sha256": sha256,
8903 "bytes": source.bytes,
8904 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
8905 }))
8906 })
8907 .collect::<LinkResult<Vec<_>>>()?
8908 } else {
8909 Vec::new()
8910 };
8911 let mut body = json!({
8912 "mutation_id": mutation_id,
8913 "base": base_value,
8914 "rebase": rebase,
8915 "reason": "dbmd sync",
8916 "operations": operations,
8917 "blobs": inline_blobs,
8918 });
8919 if !withheld_links.is_empty() {
8920 body["withheld_links"] = serde_json::to_value(&withheld_links)
8921 .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
8922 body["checkout_id"] =
8923 Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
8924 }
8925 if let Some(confirmation) = bulk_confirmation {
8926 if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
8927 return Err(LinkError::InvalidPack {
8928 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
8929 .to_string(),
8930 });
8931 }
8932 body["rebase"] = Value::String("strict".to_string());
8936 body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8937 body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8938 }
8939 if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8940 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8941 for operation in &operations {
8942 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8943 return Err(invalid_feed("v2 upload operation has no kind"));
8944 };
8945 let hash = match kind {
8946 "put" | "put_asset_content" | "restore" | "rename" => {
8947 operation.get("blob").and_then(Value::as_str)
8948 }
8949 "asset_put" | "asset_resume" => operation
8950 .get("asset")
8951 .and_then(|asset| asset.get("blob_sha256"))
8952 .and_then(Value::as_str),
8953 _ => None,
8954 };
8955 let Some(hash) = hash else { continue };
8956 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8957 if kind == "rename" {
8958 for field in ["from", "to"] {
8959 coordinates.insert(
8960 operation
8961 .get(field)
8962 .and_then(Value::as_str)
8963 .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8964 .to_string(),
8965 );
8966 }
8967 } else {
8968 let path = operation
8969 .get("path")
8970 .and_then(Value::as_str)
8971 .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8972 coordinates.insert(if kind.starts_with("asset_") {
8973 format!("assets/{path}")
8974 } else {
8975 path.to_string()
8976 });
8977 }
8978 }
8979 let declarations = upload_sources
8980 .iter()
8981 .map(|(sha256, source)| {
8982 json!({
8983 "sha256": sha256,
8984 "bytes": source.bytes,
8985 "coordinates": coordinates_by_hash
8986 .get(sha256)
8987 .into_iter()
8988 .flatten()
8989 .collect::<Vec<_>>(),
8990 })
8991 })
8992 .collect::<Vec<_>>();
8993 let mut references = Vec::with_capacity(upload_sources.len());
8994 let mut seen = std::collections::BTreeSet::new();
8995 let mut reserved_count = 0usize;
8996 let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8997 for batch in batch_upload_declarations(declarations) {
9001 let batch_len = batch.len();
9002 let reserved = reserve_upload_window(
9003 cfg,
9004 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
9005 &json!({ "blobs": batch }),
9006 "prepare v2 changed-byte uploads",
9007 )?;
9008 let items = reserved
9009 .get("uploads")
9010 .and_then(Value::as_array)
9011 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
9012 if items.len() != batch_len {
9013 return Err(invalid_feed(
9014 "v2 upload reservation response changed the requested set",
9015 ));
9016 }
9017 reserved_count += items.len();
9018 for item in items {
9019 let sha256 = item
9020 .get("sha256")
9021 .and_then(Value::as_str)
9022 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
9023 let source = upload_sources
9024 .get(sha256)
9025 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
9026 let declared_bytes = item
9027 .get("bytes")
9028 .and_then(Value::as_u64)
9029 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
9030 let reservation_id = item
9031 .get("reservation_id")
9032 .and_then(Value::as_str)
9033 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
9034 let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
9035 invalid_feed("v2 upload reservation has no coordinate binding")
9036 })?;
9037 let returned_coordinates = item
9038 .get("coordinates")
9039 .and_then(Value::as_array)
9040 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
9041 if declared_bytes != source.bytes
9042 || !crate::ulid::is_ulid(reservation_id)
9043 || !seen.insert(sha256.to_string())
9044 || returned_coordinates.len() != expected_coordinates.len()
9045 || returned_coordinates
9046 .iter()
9047 .zip(expected_coordinates)
9048 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
9049 {
9050 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
9051 }
9052 match item.get("status").and_then(Value::as_str) {
9053 Some("upload") => {
9054 let url = item
9055 .get("url")
9056 .and_then(Value::as_str)
9057 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
9058 pending_uploads.push(V2PendingUpload {
9059 url: url.to_string(),
9060 headers: item.get("headers").cloned().unwrap_or(Value::Null),
9061 sha256: sha256.to_string(),
9062 source,
9063 });
9064 }
9065 Some("already_present") => {}
9066 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
9067 }
9068 references.push(json!({
9069 "sha256": sha256,
9070 "bytes": source.bytes,
9071 "reservation_id": reservation_id,
9072 }));
9073 }
9074 upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
9080 pending_uploads.clear();
9081 }
9082 if reserved_count != upload_sources.len() {
9083 return Err(invalid_feed(
9084 "v2 upload reservation response changed the requested set",
9085 ));
9086 }
9087 body["blobs"] = Value::Array(references);
9088 }
9089 stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
9090 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
9091 let mut candidate_hub_signer: Option<String> = None;
9092 let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
9093 let bulk_preview_required = !(200..300).contains(&response.status)
9094 && response.body.as_ref().is_some_and(|value| {
9095 value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
9096 || value
9097 .get("details")
9098 .and_then(|details| details.get("code"))
9099 .and_then(Value::as_str)
9100 == Some("bulk_preview_required")
9101 });
9102 if bulk_preview_required && bulk_confirmation.is_none() {
9103 body["rebase"] = Value::String("strict".to_string());
9104 body["preview_only"] = Value::Bool(true);
9105 let preview = ensure_ok(
9106 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
9107 "v2 bulk preview",
9108 )?;
9109 let preview_code = preview.get("code").and_then(Value::as_str);
9110 let required = preview.get("required").and_then(Value::as_bool);
9111 if preview.get("v").and_then(Value::as_u64) != Some(2)
9112 || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
9113 || !matches!(
9114 preview_code,
9115 Some("bulk_preview_created" | "bulk_preview_not_required")
9116 )
9117 || required.is_none()
9118 {
9119 return Err(invalid_feed(
9120 "bulk preview response is not bound to the requested mutation",
9121 ));
9122 }
9123 if required == Some(true) {
9124 let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
9125 let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
9126 if preview_code != Some("bulk_preview_created")
9127 || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
9128 || preview_digest.is_none_or(|value| !is_sha256(value))
9129 || preview.get("expires_at").and_then(Value::as_str).is_none()
9130 || !preview.get("impact").is_some_and(Value::is_object)
9131 {
9132 return Err(invalid_feed("bulk preview receipt is malformed"));
9133 }
9134 return Err(LinkError::BulkPreviewRequired { preview });
9135 }
9136 if preview_code != Some("bulk_preview_not_required") {
9137 return Err(invalid_feed("bulk preview requirement is inconsistent"));
9138 }
9139 body.as_object_mut()
9142 .expect("v2 commit request is an object")
9143 .remove("preview_only");
9144 response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
9145 }
9146 let mut result = ensure_ok(response, "v2 sync push")?;
9147 if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
9148 if let Some(object) = result.as_object_mut() {
9149 object.insert(
9150 "sync_status".to_string(),
9151 Value::String("proposal_pending".to_string()),
9152 );
9153 }
9154 return Ok(result);
9155 }
9156 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
9157 let request_id = result
9158 .get("request_id")
9159 .and_then(Value::as_str)
9160 .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
9161 .to_string();
9162 let challenge = result
9163 .get("signing_challenge")
9164 .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
9165 let mut expected_candidate = remote.clone();
9166 let mut expected_candidate_assets = remote_assets.clone();
9167 apply_generated_v2_operations(
9168 &operations,
9169 &local_assets,
9170 &mut expected_candidate,
9171 &mut expected_candidate_assets,
9172 )?;
9173 verify_v2_markdown_asset_content_bindings(&expected_candidate, &expected_candidate_assets)?;
9174 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
9175 cfg,
9176 &head,
9177 &expected_candidate,
9178 &expected_candidate_assets,
9179 &mutation_id,
9180 &v2_signed_request_view(&body, &operations),
9181 challenge,
9182 )?;
9183 body["signing_challenge_id"] = Value::String(challenge_id);
9184 body["signature_base64url"] = Value::String(signature);
9185 candidate_hub_signer = Some(actor_signer);
9186 result = ensure_ok(
9187 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
9188 "v2 self-custody commit",
9189 )?;
9190 }
9191 let refreshed = v2_verified_head(cfg, requested_brain)?
9192 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
9193 if candidate_hub_signer
9194 .as_ref()
9195 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
9196 {
9197 return Err(invalid_feed(
9198 "self-custody actor signer differs from the committed hub pointer signer",
9199 ));
9200 }
9201 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
9202 if refreshed
9203 .pointer
9204 .as_ref()
9205 .map(|pointer| pointer.commit_hash.as_str())
9206 != accepted_hash
9207 {
9208 return Err(LinkError::RemoteAdvancedDuringSync);
9209 }
9210 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
9211 let rebased = result
9212 .get("rebased")
9213 .and_then(Value::as_bool)
9214 .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
9215 let (refreshed_files, refreshed_assets) = if rebased {
9216 (
9217 files_for_v2_view(
9218 &refreshed,
9219 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
9220 ),
9221 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
9222 )
9223 } else {
9224 let asset_changed = apply_generated_v2_operations(
9225 &operations,
9226 &local_assets,
9227 &mut remote,
9228 &mut remote_assets,
9229 )?;
9230 let assets = if asset_changed {
9231 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
9234 } else {
9235 remote_assets
9236 };
9237 (remote, assets)
9238 };
9239 verify_v2_markdown_asset_content_bindings(&refreshed_files, &refreshed_assets)?;
9240 let mut final_local = v2_local_files_cached(
9241 store,
9242 Some((&local_view.policy.digest, &local_view.scan_cache)),
9243 )?;
9244 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
9245 let final_assets = v2_local_asset_records(store)?;
9246 let local_dirty = final_local.riding != local_view.riding
9247 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
9248 final_local.policy.keeps_home(path)
9249 })
9250 || final_assets != local_assets
9251 || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
9252 let contract_handoff = contract_phase.then(|| V2PulledSnapshot {
9258 report: PullReport {
9259 brain: refreshed.brain_id.clone(),
9260 slug: requested_brain.to_string(),
9261 head_seq: refreshed.pointer.as_ref().map_or(0, |pointer| pointer.seq),
9262 files: refreshed_files.len(),
9263 dest: store.root.to_string_lossy().into_owned(),
9264 extra_local: Vec::new(),
9265 sync_status: "contract_phase".to_string(),
9266 },
9267 head: refreshed.clone(),
9268 files: refreshed_files.clone(),
9269 assets: refreshed_assets.clone(),
9270 local: final_local.clone(),
9271 local_assets: final_assets.clone(),
9272 });
9273 let next = v2_baseline_from_head(
9274 cfg,
9275 &refreshed,
9276 refreshed_files,
9277 refreshed_assets,
9278 Some(&final_local),
9279 Some(&checkout_pseudonym),
9280 )?;
9281 let split_count = next.remote_copy_remains.len();
9282 accept_v2_head(cfg, &refreshed)?;
9283 if !local_dirty {
9284 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
9285 }
9286 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
9291 if let Some(object) = result.as_object_mut() {
9292 object.insert(
9293 "local_policy".to_string(),
9294 json!({ "remote_copy_remains": split_count }),
9295 );
9296 object.insert(
9297 "sync_status".to_string(),
9298 Value::String(if local_dirty {
9299 "remote_committed_local_dirty".to_string()
9300 } else {
9301 "synced".to_string()
9302 }),
9303 );
9304 }
9305 if contract_phase && result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9306 let contract_receipt = result;
9307 drop(_operation_lock);
9312 let mut remaining = v2_sync_push(
9313 cfg,
9314 requested_brain,
9315 store,
9316 refreshed,
9317 V2SyncPushOptions {
9318 resume_local_policy,
9319 bulk_confirmation,
9320 resolution,
9321 pulled: contract_handoff,
9322 withdrawal_paths,
9323 withdrawal_reason,
9324 allow_contract_phase: false,
9325 },
9326 )?;
9327 if let Some(object) = remaining.as_object_mut() {
9328 object.insert("contract_phase".to_string(), contract_receipt);
9329 }
9330 return Ok(remaining);
9331 }
9332 if contract_phase {
9333 if let Some(object) = result.as_object_mut() {
9334 object.insert("contract_phase_pending".to_string(), Value::Bool(true));
9335 }
9336 }
9337 Ok(result)
9338}
9339
9340pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
9343 sync_push_incremental_with_policy(cfg, brain, store, false)
9344}
9345
9346pub fn sync_push_incremental_with_policy(
9349 cfg: &HubConfig,
9350 brain: &str,
9351 store: &Store,
9352 resume_local_policy: bool,
9353) -> LinkResult<Value> {
9354 sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
9355}
9356
9357pub fn sync_push_incremental_with_options(
9360 cfg: &HubConfig,
9361 brain: &str,
9362 store: &Store,
9363 resume_local_policy: bool,
9364 bulk_confirmation: Option<&V2BulkConfirmation>,
9365) -> LinkResult<Value> {
9366 sync_push_incremental_with_controls(
9367 cfg,
9368 brain,
9369 store,
9370 resume_local_policy,
9371 bulk_confirmation,
9372 &[],
9373 None,
9374 )
9375}
9376
9377pub fn sync_push_incremental_with_controls(
9379 cfg: &HubConfig,
9380 brain: &str,
9381 store: &Store,
9382 resume_local_policy: bool,
9383 bulk_confirmation: Option<&V2BulkConfirmation>,
9384 withdrawal_paths: &[String],
9385 withdrawal_reason: Option<&str>,
9386) -> LinkResult<Value> {
9387 require_safe_ref(brain)?;
9388 if let Some(head) = v2_verified_head(cfg, brain)? {
9389 return v2_sync_push(
9390 cfg,
9391 brain,
9392 store,
9393 head,
9394 V2SyncPushOptions {
9395 resume_local_policy,
9396 bulk_confirmation,
9397 resolution: None,
9398 pulled: None,
9399 withdrawal_paths,
9400 withdrawal_reason,
9401 allow_contract_phase: true,
9402 },
9403 );
9404 }
9405 if !withdrawal_paths.is_empty() {
9406 return Err(LinkError::InvalidPack {
9407 message: "explicit withdrawal requires a link.md v2 brain".to_string(),
9408 });
9409 }
9410 legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
9411}
9412
9413pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
9417 require_safe_ref(brain)?;
9418 Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
9419}
9420
9421pub fn relocate_v2_sync_baseline(
9427 cfg: &HubConfig,
9428 brain: &str,
9429 from: &Path,
9430 to: &Path,
9431) -> LinkResult<Value> {
9432 require_hardened_filesystem("verified link.md v2 baseline relocation")?;
9433 require_safe_ref(brain)?;
9434 if !crate::ulid::is_ulid(brain) {
9435 return Err(invalid_feed(
9436 "v2 baseline relocation requires the canonical brain id",
9437 ));
9438 }
9439 let from_absolute = if from.is_absolute() {
9440 from.to_path_buf()
9441 } else {
9442 std::env::current_dir()?.join(from)
9443 };
9444 let to_absolute = if to.is_absolute() {
9445 to.to_path_buf()
9446 } else {
9447 std::env::current_dir()?.join(to)
9448 };
9449 let source_name = v2_baseline_name(cfg, brain, &from_absolute)?;
9450 let target_name = v2_baseline_name(cfg, brain, &to_absolute)?;
9451 if source_name == target_name {
9452 return Err(invalid_feed(
9453 "v2 baseline relocation source and destination are the same checkout",
9454 ));
9455 }
9456 match std::fs::symlink_metadata(&from_absolute) {
9457 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
9458 Err(error) => return Err(error.into()),
9459 Ok(_) => {
9460 return Err(LinkError::InvalidPack {
9461 message: "the old checkout still exists; move it before relocating its baseline"
9462 .to_string(),
9463 })
9464 }
9465 }
9466 let store = Store::open_strict(&to_absolute).map_err(|error| LinkError::InvalidPack {
9467 message: format!("relocated checkout is not a valid db.md store: {error}"),
9468 })?;
9469 let _operation_lock = lock_v2_sync_operation(cfg, brain)?;
9470
9471 #[cfg(any(unix, windows))]
9472 {
9473 let directory = open_trust_dir(cfg)?;
9474 let mut lock_names = [source_name.as_str(), target_name.as_str()];
9475 lock_names.sort();
9476 let _locks = lock_names
9477 .iter()
9478 .map(|name| lock_trust_name(&directory, name))
9479 .collect::<LinkResult<Vec<_>>>()?;
9480 let source = load_v2_baseline_in(cfg, brain, &directory, &source_name)?;
9481 let target = load_v2_baseline_in(cfg, brain, &directory, &target_name)?;
9482 let (baseline, already_relocated) = match (source, target) {
9483 (Some(source), None) => (source, false),
9484 (None, Some(target)) => (target, true),
9485 (Some(_), Some(_)) => {
9486 return Err(LinkError::InvalidPack {
9487 message: "both old and new checkout paths already have private sync baselines"
9488 .to_string(),
9489 })
9490 }
9491 (None, None) => {
9492 return Err(LinkError::InvalidPack {
9493 message: "the old checkout has no verified incremental baseline to relocate"
9494 .to_string(),
9495 })
9496 }
9497 };
9498
9499 let mut local = v2_local_files(&store)?;
9500 if baseline.view_kind.as_deref() == Some("scoped") {
9501 let expected = baseline
9502 .projection_sha256
9503 .as_deref()
9504 .ok_or_else(|| invalid_feed("scoped baseline has no projection hash"))?;
9505 if local.riding.get("DB.md").map(|value| value.0.as_str()) != Some(expected) {
9506 return Err(LinkError::ScopedProjectionModified);
9507 }
9508 local.riding.remove("DB.md");
9509 }
9510 if baseline.local_policy_digest.as_deref() != Some(local.policy.digest.as_str())
9511 || !v2_riding_matches_remote(&local.riding, &baseline.files, |path| {
9512 local.policy.keeps_home(path)
9513 })
9514 || !v2_asset_records_match_remote(&v2_local_asset_records(&store)?, &baseline.assets)
9515 {
9516 return Err(LinkError::InvalidPack {
9517 message: "the moved checkout no longer matches its verified incremental baseline"
9518 .to_string(),
9519 });
9520 }
9521 if !already_relocated {
9522 crate::fsx::rename_beneath(
9523 &directory,
9524 Path::new(&source_name),
9525 Path::new(&target_name),
9526 )?;
9527 directory.sync_all()?;
9528 }
9529 Ok(json!({
9530 "v": 2,
9531 "class": "checkout_baseline_relocated",
9532 "brain": baseline.brain,
9533 "from": from_absolute,
9534 "to": to_absolute,
9535 "headSeq": baseline.head_seq.unwrap_or(0),
9536 "commitHash": baseline.commit_hash,
9537 "moved": !already_relocated,
9538 }))
9539 }
9540
9541 #[cfg(not(any(unix, windows)))]
9542 Err(LinkError::UnsupportedPlatform {
9543 operation: "verified link.md v2 baseline relocation",
9544 })
9545}
9546
9547#[cfg(windows)]
9548fn legacy_sync_push_incremental(
9549 _cfg: &HubConfig,
9550 _brain: &str,
9551 _store: &Store,
9552 _resume_local_policy: bool,
9553 _bulk_confirmation: Option<&V2BulkConfirmation>,
9554) -> LinkResult<Value> {
9555 Err(LinkError::UnsupportedPlatform {
9556 operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
9557 })
9558}
9559
9560#[cfg(not(windows))]
9561fn legacy_sync_push_incremental(
9562 cfg: &HubConfig,
9563 brain: &str,
9564 store: &Store,
9565 resume_local_policy: bool,
9566 bulk_confirmation: Option<&V2BulkConfirmation>,
9567) -> LinkResult<Value> {
9568 if resume_local_policy || bulk_confirmation.is_some() {
9569 return Err(LinkError::InvalidPack {
9570 message: "v2 sync options require a link.md v2 brain".to_string(),
9571 });
9572 }
9573 let files = collect_push_files(store)?;
9574 sync_push(cfg, brain, &files)
9575}
9576
9577#[derive(Debug, Clone)]
9579pub enum V2ConflictChoice {
9580 KeepLocal,
9581 TakeRemote,
9582 From(PathBuf),
9583}
9584
9585fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
9586 if !crate::ulid::is_ulid(bundle) {
9587 return Err(LinkError::InvalidPack {
9588 message: "conflict bundle must be a lowercase ULID".to_string(),
9589 });
9590 }
9591 let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
9592 let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
9593 .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
9594 if plan.v != 2
9595 || plan.class != "content_resolution_required"
9596 || plan.bundle != bundle
9597 || !crate::ulid::is_ulid(&plan.brain)
9598 || plan.files.is_empty()
9599 || plan.files.len() > 100
9600 || plan.files.iter().any(|file| {
9601 crate::linkmd_v2::normalize_path(&file.path).is_err()
9602 || [&file.base, &file.local, &file.remote]
9603 .into_iter()
9604 .any(|coordinate| {
9605 coordinate
9606 .sha256
9607 .as_deref()
9608 .is_some_and(|hash| !is_sha256(hash))
9609 || coordinate.file.as_deref().is_some_and(|name| {
9610 name.starts_with('/')
9611 || name
9612 .split('/')
9613 .any(|part| part.is_empty() || part == "." || part == "..")
9614 })
9615 })
9616 })
9617 {
9618 return Err(invalid_feed("private conflict plan failed validation"));
9619 }
9620 Ok(plan)
9621}
9622
9623pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
9628 require_hardened_filesystem("private conflict maintenance")?;
9629 if all && !prune {
9630 return Err(LinkError::InvalidPack {
9631 message: "discarding all conflict bundles requires prune=true".to_string(),
9632 });
9633 }
9634 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9635 message: format!("conflict checkout is not a valid db.md store: {error}"),
9636 })?;
9637 let _transaction = store.transaction()?;
9638 let root = Path::new(".dbmd/conflicts");
9639 let names = match store.directory_names(root) {
9640 Ok(names) => names,
9641 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
9642 Err(error) => return Err(error.into()),
9643 };
9644 let now = SystemTime::now()
9645 .duration_since(UNIX_EPOCH)
9646 .unwrap_or_default()
9647 .as_secs();
9648 let mut bundles = Vec::new();
9649 let mut pruned = 0_u64;
9650 for name in names {
9651 let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
9652 continue;
9653 };
9654 let plan_path = v2_conflict_relative(bundle, "plan.json");
9655 let plan_exists = store.regular_file_exists(&plan_path)?;
9656 let expired = if plan_exists {
9657 match load_v2_conflict_plan(&store, bundle) {
9658 Ok(plan) => plan.expires_unix < now,
9659 Err(error) if all => {
9660 let _ = error;
9661 true
9662 }
9663 Err(error) => return Err(error),
9664 }
9665 } else {
9666 true
9667 };
9668 if prune && (all || expired) {
9669 store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9670 pruned += 1;
9671 continue;
9672 }
9673 bundles.push(json!({
9674 "bundle": bundle,
9675 "complete": plan_exists,
9676 "expired": expired,
9677 }));
9678 }
9679 Ok(json!({
9680 "v": 2,
9681 "class": "private_conflict_state",
9682 "bundles": bundles.len(),
9683 "pruned": pruned,
9684 "items": bundles,
9685 }))
9686}
9687
9688pub fn sync_resolve_conflict(
9692 cfg: &HubConfig,
9693 checkout: &Path,
9694 bundle: &str,
9695 choice: V2ConflictChoice,
9696 bulk_confirmation: Option<&V2BulkConfirmation>,
9697) -> LinkResult<Value> {
9698 require_hardened_filesystem("conflict resolution")?;
9699 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9700 message: format!("conflict checkout is not a valid db.md store: {error}"),
9701 })?;
9702 let plan = load_v2_conflict_plan(&store, bundle)?;
9703 if plan.origin != normalized_origin(&cfg.hub)? {
9704 return Err(invalid_feed(
9705 "conflict bundle belongs to another hub origin",
9706 ));
9707 }
9708 let now = SystemTime::now()
9709 .duration_since(UNIX_EPOCH)
9710 .unwrap_or_default()
9711 .as_secs();
9712 if now > plan.expires_unix {
9713 return Err(LinkError::InvalidPack {
9714 message: "conflict bundle expired; rerun sync to obtain current coordinates"
9715 .to_string(),
9716 });
9717 }
9718 let head = v2_verified_head(cfg, &plan.brain)?
9719 .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
9720 let pointer = head.pointer.as_ref();
9721 if pointer.map_or(0, |value| value.seq) != plan.remote_seq
9722 || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
9723 || pointer.and_then(|value| value.content_root.as_deref())
9724 != plan.remote_content_root.as_deref()
9725 || head.view_kind != plan.view_kind
9726 || head.view_revision != plan.view_revision
9727 {
9728 return Err(LinkError::RemoteAdvancedDuringSync);
9729 }
9730
9731 for file in &plan.files {
9733 let actual = match store.regular_file_exists(Path::new(&file.path))? {
9734 true => Some(content_sha256(&store.read_bounded(
9735 Path::new(&file.path),
9736 file.local.bytes.unwrap_or(MAX_STORE_BYTES),
9737 )?)),
9738 false => None,
9739 };
9740 if actual.as_deref() != file.local.sha256.as_deref() {
9741 return Err(LinkError::InvalidPack {
9742 message: format!(
9743 "local conflict path `{}` changed after the bundle was created",
9744 file.path
9745 ),
9746 });
9747 }
9748 }
9749
9750 let from_source = match &choice {
9751 V2ConflictChoice::From(source) => Some(source.clone()),
9752 _ => None,
9753 };
9754 let result = match choice {
9755 V2ConflictChoice::TakeRemote => {
9756 if bulk_confirmation.is_some() {
9757 return Err(LinkError::InvalidPack {
9758 message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
9759 });
9760 }
9761 let current_remote =
9765 files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
9766 let _ = v2_take_remote_selection(&plan.files, ¤t_remote)?;
9767 let selected = plan
9768 .files
9769 .iter()
9770 .map(|file| file.path.clone())
9771 .collect::<std::collections::BTreeSet<_>>();
9772 serde_json::to_value(
9773 v2_sync_pull_with_resolution(
9774 cfg,
9775 &plan.brain,
9776 head,
9777 Some(checkout),
9778 Some(&selected),
9779 )?
9780 .report,
9781 )
9782 .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
9783 }
9784 V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
9785 if let Some(source) = from_source.as_ref() {
9786 if plan.files.len() != 1 {
9787 return Err(LinkError::InvalidPack {
9788 message: "--from requires a bundle with exactly one conflict".to_string(),
9789 });
9790 }
9791 let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
9792 if std::str::from_utf8(&candidate).is_err() {
9793 return Err(LinkError::NotUtf8 {
9794 path: source.display().to_string(),
9795 });
9796 }
9797 store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
9798 }
9799 let refreshed_store =
9800 Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9801 message: format!("resolved checkout is not a valid db.md store: {error}"),
9802 })?;
9803 let mut overrides = std::collections::BTreeMap::new();
9804 for file in &plan.files {
9805 let selected_local = match refreshed_store
9806 .regular_file_exists(Path::new(&file.path))?
9807 {
9808 true => Some(content_sha256(
9809 &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
9810 )),
9811 false => None,
9812 };
9813 overrides.insert(
9814 file.path.clone(),
9815 V2ResolutionOverride {
9816 expected_remote: file.remote.sha256.clone(),
9817 selected_local,
9818 },
9819 );
9820 }
9821 v2_sync_push(
9822 cfg,
9823 &plan.brain,
9824 &refreshed_store,
9825 head,
9826 V2SyncPushOptions {
9827 resume_local_policy: true,
9828 bulk_confirmation,
9829 resolution: Some(&overrides),
9830 pulled: None,
9831 withdrawal_paths: &[],
9832 withdrawal_reason: None,
9833 allow_contract_phase: true,
9834 },
9835 )?
9836 }
9837 };
9838
9839 if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9840 let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9841 message: format!("resolved checkout is not a valid db.md store: {error}"),
9842 })?;
9843 installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9844 }
9845 Ok(json!({
9846 "v": 2,
9847 "class": "auto_converged",
9848 "bundle": bundle,
9849 "receipt": result,
9850 }))
9851}
9852
9853pub fn sync_converge(
9864 cfg: &HubConfig,
9865 brain: &str,
9866 checkout: &Path,
9867 resume_local_policy: bool,
9868) -> LinkResult<Value> {
9869 sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
9870}
9871
9872pub fn sync_converge_with_options(
9874 cfg: &HubConfig,
9875 brain: &str,
9876 checkout: &Path,
9877 resume_local_policy: bool,
9878 bulk_confirmation: Option<&V2BulkConfirmation>,
9879) -> LinkResult<Value> {
9880 sync_converge_with_controls(
9881 cfg,
9882 brain,
9883 checkout,
9884 resume_local_policy,
9885 bulk_confirmation,
9886 &[],
9887 None,
9888 )
9889}
9890
9891pub fn sync_converge_with_controls(
9893 cfg: &HubConfig,
9894 brain: &str,
9895 checkout: &Path,
9896 resume_local_policy: bool,
9897 bulk_confirmation: Option<&V2BulkConfirmation>,
9898 withdrawal_paths: &[String],
9899 withdrawal_reason: Option<&str>,
9900) -> LinkResult<Value> {
9901 require_hardened_filesystem("bidirectional sync")?;
9902 require_safe_ref(brain)?;
9903 let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
9904 message:
9905 "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
9906 .to_string(),
9907 })?;
9908 let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
9909 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9910 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9911 })?;
9912 let _transaction = store.transaction()?;
9913 let pulled_report = pulled.report.clone();
9914 let pulled_head = pulled.head.clone();
9915 let mut result = v2_sync_push(
9916 cfg,
9917 brain,
9918 &store,
9919 pulled_head,
9920 V2SyncPushOptions {
9921 resume_local_policy,
9922 bulk_confirmation,
9923 resolution: None,
9924 pulled: Some(pulled),
9925 withdrawal_paths,
9926 withdrawal_reason,
9927 allow_contract_phase: true,
9928 },
9929 )?;
9930 if let Some(object) = result.as_object_mut() {
9931 object.insert("pulled_files".to_string(), json!(pulled_report.files));
9932 object.insert("checkout".to_string(), Value::String(pulled_report.dest));
9933 object.insert(
9934 "mode".to_string(),
9935 Value::String("bidirectional".to_string()),
9936 );
9937 }
9938 Ok(result)
9939}
9940
9941pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9947 require_hardened_filesystem("sync pull")?;
9948 require_safe_ref(brain)?;
9949 if let Some(head) = v2_verified_head(cfg, brain)? {
9950 return v2_sync_pull(cfg, brain, head, out);
9951 }
9952 legacy_sync_pull(cfg, brain, out)
9953}
9954
9955#[cfg(windows)]
9956fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
9957 Err(LinkError::UnsupportedPlatform {
9958 operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
9959 })
9960}
9961
9962#[cfg(not(windows))]
9963fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9964 let remote = verified_remote_head(cfg, brain, false)?;
9965 if !remote.head.verified {
9966 return Err(invalid_feed(
9967 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
9968 ));
9969 }
9970 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
9971 let path = format!(
9972 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
9973 remote.head.seq
9974 );
9975 let body = ensure_ok(
9976 request(cfg, "GET", &path, None, Auth::Required)?,
9977 "sync pull",
9978 )?;
9979 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
9980 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
9981 {
9982 return Err(invalid_feed(
9983 "export response is not bound to the verified snapshot token",
9984 ));
9985 }
9986
9987 let remote_slug = body
9988 .get("slug")
9989 .and_then(Value::as_str)
9990 .filter(|slug| is_safe_slug(slug));
9991 let slug = remote_slug
9992 .or_else(|| is_safe_slug(brain).then_some(brain))
9993 .unwrap_or("brain")
9994 .to_string();
9995 let brain_id = body
9996 .get("brain")
9997 .and_then(Value::as_str)
9998 .unwrap_or(&remote.head.brain)
9999 .to_string();
10000 if brain_id != remote.head.brain {
10001 return Err(invalid_feed(
10002 "export response names a different brain than the verified head",
10003 ));
10004 }
10005 let head_seq = remote.head.seq;
10006 let dest: PathBuf = match out {
10007 Some(p) => p.to_path_buf(),
10008 None => PathBuf::from(&slug),
10009 };
10010 let entries = if head_seq == 0 {
10011 let files = body
10012 .get("files")
10013 .and_then(Value::as_array)
10014 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
10015 if !files.is_empty() || body.get("url").is_some() {
10016 return Err(invalid_feed(
10017 "empty signed feed cannot authorize non-empty exported content",
10018 ));
10019 }
10020 Vec::new()
10021 } else {
10022 let signed_head = remote
10023 .head_entry
10024 .as_ref()
10025 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
10026 let expected = &signed_head.entry.pack_sha256;
10027 if !is_sha256(expected) {
10028 return Err(invalid_feed(
10029 "signed head carries an invalid snapshot pack digest",
10030 ));
10031 }
10032 if let Some(url) = body.get("url").and_then(Value::as_str) {
10033 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
10034 return Err(invalid_feed(
10035 "export pack digest does not match the signed head entry",
10036 ));
10037 }
10038 let bytes = get_presigned(cfg, url)?;
10039 let actual = format!("{:x}", Sha256::digest(&bytes));
10040 if actual != *expected {
10041 return Err(LinkError::InvalidPack {
10042 message: "downloaded pack does not match the signed snapshot digest"
10043 .to_string(),
10044 });
10045 }
10046 let entries = parse_store_pack(bytes)?;
10047 if signed_head.entry.kind == "push" {
10048 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
10049 }
10050 entries
10051 } else {
10052 if signed_head.entry.kind != "push" {
10053 return Err(invalid_feed(
10054 "delta snapshots must export the exact signed pack",
10055 ));
10056 }
10057 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
10058 invalid_feed("verified snapshot export carried neither a pack nor files")
10059 })?;
10060 let mut entries = Vec::with_capacity(files.len());
10061 for file in files {
10062 let path = file
10063 .get("path")
10064 .and_then(Value::as_str)
10065 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
10066 let content = file
10067 .get("content")
10068 .and_then(Value::as_str)
10069 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
10070 entries.push((path.to_string(), content.as_bytes().to_vec()));
10071 }
10072 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
10073 entries
10074 }
10075 };
10076
10077 let mut seen = std::collections::HashSet::new();
10079 for (path, _) in &entries {
10080 if !safe_store_rel_path(path) {
10081 return Err(LinkError::UnsafePath { path: path.clone() });
10082 }
10083 if !seen.insert(path) {
10084 return Err(LinkError::InvalidPack {
10085 message: format!("duplicate path `{path}`"),
10086 });
10087 }
10088 }
10089 let pulled: std::collections::BTreeSet<&str> =
10092 entries.iter().map(|(p, _)| p.as_str()).collect();
10093 let mut extra_local = Vec::new();
10094 if let Ok(store) = Store::open(&dest) {
10095 if let Ok(walked) = store.walk() {
10096 for rel in walked {
10097 let rel_str = rel.to_string_lossy().replace('\\', "/");
10098 if !pulled.contains(rel_str.as_str()) {
10099 extra_local.push(rel_str);
10100 }
10101 }
10102 }
10103 }
10104 #[cfg(unix)]
10105 install_pulled_snapshot(&dest, &entries)?;
10106
10107 Ok(PullReport {
10108 brain: brain_id,
10109 slug,
10110 head_seq,
10111 files: entries.len(),
10112 dest: dest.to_string_lossy().into_owned(),
10113 extra_local,
10114 sync_status: "synced".to_string(),
10115 })
10116}
10117
10118#[cfg(unix)]
10119fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
10120 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
10121 path: display.to_string(),
10122 })
10123}
10124
10125#[cfg(unix)]
10126fn open_dir_at(
10127 parent: std::os::fd::RawFd,
10128 name: &std::ffi::CStr,
10129 display: &str,
10130) -> LinkResult<std::fs::File> {
10131 use std::os::fd::FromRawFd as _;
10132 let fd = unsafe {
10133 libc::openat(
10134 parent,
10135 name.as_ptr(),
10136 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10137 )
10138 };
10139 if fd < 0 {
10140 return Err(LinkError::UnsafePath {
10141 path: display.to_string(),
10142 });
10143 }
10144 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
10145}
10146
10147#[cfg(unix)]
10151fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
10152 use std::os::fd::AsRawFd as _;
10153
10154 #[cfg(target_os = "macos")]
10158 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
10159 .into_iter()
10160 .find_map(|(alias, real)| {
10161 path.strip_prefix(alias)
10162 .ok()
10163 .map(|rest| Path::new(real).join(rest))
10164 })
10165 .unwrap_or_else(|| path.to_path_buf());
10166 #[cfg(not(target_os = "macos"))]
10167 let normalized = path.to_path_buf();
10168
10169 let start = if normalized.is_absolute() {
10170 std::fs::File::open("/")?
10171 } else {
10172 std::fs::File::open(".")?
10173 };
10174 let mut directory = start;
10175 for component in normalized.components() {
10176 use std::path::Component;
10177 let name = match component {
10178 Component::RootDir | Component::CurDir => continue,
10179 Component::Normal(name) => name,
10180 Component::ParentDir | Component::Prefix(_) => {
10181 return Err(LinkError::UnsafePath {
10182 path: path.display().to_string(),
10183 });
10184 }
10185 };
10186 use std::os::unix::ffi::OsStrExt as _;
10187 let name = c_name(name.as_bytes(), &path.display().to_string())?;
10188 if create {
10189 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10190 if made != 0 {
10191 let error = std::io::Error::last_os_error();
10192 if error.raw_os_error() != Some(libc::EEXIST) {
10193 return Err(error.into());
10194 }
10195 }
10196 }
10197 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
10198 }
10199 Ok(directory)
10200}
10201
10202#[cfg(unix)]
10203fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
10204 open_dir_path_nofollow(path, true)
10205}
10206
10207#[cfg(unix)]
10208fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
10209 open_dir_path_nofollow(path, false)
10210}
10211
10212#[cfg(unix)]
10213fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
10214 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
10215 let result =
10216 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
10217 if result == 0 {
10218 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
10219 }
10220 let error = std::io::Error::last_os_error();
10221 if error.kind() == std::io::ErrorKind::NotFound {
10222 Ok(None)
10223 } else {
10224 Err(error.into())
10225 }
10226}
10227
10228#[cfg(unix)]
10229fn create_dir_exclusive_at(
10230 parent: std::os::fd::RawFd,
10231 name: &std::ffi::CStr,
10232 display: &str,
10233) -> LinkResult<std::fs::File> {
10234 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
10235 if made != 0 {
10236 return Err(LinkError::UnsafePath {
10237 path: display.to_string(),
10238 });
10239 }
10240 open_dir_at(parent, name, display)
10241}
10242
10243#[cfg(unix)]
10244fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
10245 use std::os::fd::AsRawFd as _;
10246
10247 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
10248 if duplicate < 0 {
10249 return Err(std::io::Error::last_os_error().into());
10250 }
10251 let stream = unsafe { libc::fdopendir(duplicate) };
10252 if stream.is_null() {
10253 let error = std::io::Error::last_os_error();
10254 unsafe {
10255 libc::close(duplicate);
10256 }
10257 return Err(error.into());
10258 }
10259 let mut names = Vec::new();
10260 loop {
10261 let entry = unsafe { libc::readdir(stream) };
10262 if entry.is_null() {
10263 break;
10264 }
10265 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
10266 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
10267 names.push(raw.to_owned());
10268 }
10269 }
10270 if unsafe { libc::closedir(stream) } != 0 {
10271 return Err(std::io::Error::last_os_error().into());
10272 }
10273 Ok(names)
10274}
10275
10276#[cfg(unix)]
10279fn remove_tree_at(
10280 parent: std::os::fd::RawFd,
10281 name: &std::ffi::CStr,
10282 display: &str,
10283) -> LinkResult<()> {
10284 use std::os::fd::AsRawFd as _;
10285
10286 match entry_is_dir_at(parent, name)? {
10287 None => return Ok(()),
10288 Some(false) => {
10289 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
10290 return Err(std::io::Error::last_os_error().into());
10291 }
10292 }
10293 Some(true) => {
10294 let directory = open_dir_at(parent, name, display)?;
10295 for child in directory_entry_names(&directory)? {
10296 let child_display =
10297 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
10298 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
10299 }
10300 drop(directory);
10301 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
10302 return Err(std::io::Error::last_os_error().into());
10303 }
10304 }
10305 }
10306 Ok(())
10307}
10308
10309#[cfg(unix)]
10313fn clone_tree_contents(
10314 source: &std::fs::File,
10315 destination: &std::fs::File,
10316 display: &str,
10317) -> LinkResult<()> {
10318 use std::os::fd::{AsRawFd as _, FromRawFd as _};
10319
10320 for name in directory_entry_names(source)? {
10321 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
10322 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
10323 if unsafe {
10324 libc::fstatat(
10325 source.as_raw_fd(),
10326 name.as_ptr(),
10327 &mut stat,
10328 libc::AT_SYMLINK_NOFOLLOW,
10329 )
10330 } != 0
10331 {
10332 return Err(std::io::Error::last_os_error().into());
10333 }
10334 match stat.st_mode & libc::S_IFMT {
10335 libc::S_IFDIR => {
10336 if unsafe {
10337 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
10338 } != 0
10339 {
10340 return Err(std::io::Error::last_os_error().into());
10341 }
10342 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
10343 let destination_child =
10344 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
10345 clone_tree_contents(&source_child, &destination_child, &child_display)?;
10346 destination_child.sync_all()?;
10347 }
10348 libc::S_IFREG => {
10349 let source_fd = unsafe {
10350 libc::openat(
10351 source.as_raw_fd(),
10352 name.as_ptr(),
10353 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10354 )
10355 };
10356 if source_fd < 0 {
10357 return Err(std::io::Error::last_os_error().into());
10358 }
10359 let destination_fd = unsafe {
10360 libc::openat(
10361 destination.as_raw_fd(),
10362 name.as_ptr(),
10363 libc::O_WRONLY
10364 | libc::O_CREAT
10365 | libc::O_EXCL
10366 | libc::O_CLOEXEC
10367 | libc::O_NOFOLLOW,
10368 (stat.st_mode & 0o777) as libc::c_uint,
10369 )
10370 };
10371 if destination_fd < 0 {
10372 unsafe {
10373 libc::close(source_fd);
10374 }
10375 return Err(std::io::Error::last_os_error().into());
10376 }
10377 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
10378 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
10379 std::io::copy(&mut input, &mut output)?;
10380 output.sync_all()?;
10381 }
10382 libc::S_IFLNK => {
10383 let mut target = vec![0_u8; 4097];
10384 let length = unsafe {
10385 libc::readlinkat(
10386 source.as_raw_fd(),
10387 name.as_ptr(),
10388 target.as_mut_ptr().cast(),
10389 target.len(),
10390 )
10391 };
10392 if length < 0 || length as usize >= target.len() {
10393 return Err(LinkError::UnsafePath {
10394 path: child_display,
10395 });
10396 }
10397 target.truncate(length as usize);
10398 let target = c_name(&target, &child_display)?;
10399 if unsafe {
10400 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
10401 } != 0
10402 {
10403 return Err(std::io::Error::last_os_error().into());
10404 }
10405 }
10406 _ => {
10407 return Err(LinkError::UnsafePath {
10408 path: child_display,
10409 });
10410 }
10411 }
10412 }
10413 destination.sync_all()?;
10414 Ok(())
10415}
10416
10417#[cfg(target_os = "linux")]
10418fn install_stage_at(
10419 parent: std::os::fd::RawFd,
10420 stage: &std::ffi::CStr,
10421 dest: &std::ffi::CStr,
10422 dest_exists: bool,
10423) -> LinkResult<()> {
10424 let flags = if dest_exists {
10425 libc::RENAME_EXCHANGE
10426 } else {
10427 libc::RENAME_NOREPLACE
10428 };
10429 let result = unsafe {
10433 libc::syscall(
10434 libc::SYS_renameat2,
10435 parent,
10436 stage.as_ptr(),
10437 parent,
10438 dest.as_ptr(),
10439 flags,
10440 )
10441 };
10442 if result == 0 {
10443 Ok(())
10444 } else {
10445 Err(std::io::Error::last_os_error().into())
10446 }
10447}
10448
10449#[cfg(target_os = "macos")]
10450fn install_stage_at(
10451 parent: std::os::fd::RawFd,
10452 stage: &std::ffi::CStr,
10453 dest: &std::ffi::CStr,
10454 dest_exists: bool,
10455) -> LinkResult<()> {
10456 let flags = if dest_exists {
10457 libc::RENAME_SWAP
10458 } else {
10459 libc::RENAME_EXCL
10460 };
10461 let result =
10462 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
10463 if result == 0 {
10464 Ok(())
10465 } else {
10466 Err(std::io::Error::last_os_error().into())
10467 }
10468}
10469
10470#[cfg(unix)]
10471fn write_pull_entries_beneath_dir(
10472 root: &std::fs::File,
10473 entries: &[(String, Vec<u8>)],
10474) -> LinkResult<()> {
10475 use std::os::fd::{AsRawFd as _, FromRawFd as _};
10476
10477 for (path, content) in entries {
10478 let components: Vec<&str> = path.split('/').collect();
10479 let (leaf, parents) = components
10480 .split_last()
10481 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10482 let mut directory = root.try_clone()?;
10483 for component in parents {
10484 let name = c_name(component.as_bytes(), path)?;
10485 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10486 if made != 0 {
10487 let error = std::io::Error::last_os_error();
10488 if error.raw_os_error() != Some(libc::EEXIST) {
10489 return Err(error.into());
10490 }
10491 }
10492 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10493 }
10494
10495 let leaf_name = c_name(leaf.as_bytes(), path)?;
10496 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
10497 let inspected = unsafe {
10498 libc::fstatat(
10499 directory.as_raw_fd(),
10500 leaf_name.as_ptr(),
10501 &mut existing,
10502 libc::AT_SYMLINK_NOFOLLOW,
10503 )
10504 };
10505 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
10506 return Err(LinkError::UnsafePath { path: path.clone() });
10507 }
10508
10509 let nonce = std::time::SystemTime::now()
10510 .duration_since(std::time::UNIX_EPOCH)
10511 .unwrap_or_default()
10512 .as_nanos();
10513 let temp_name = format!(
10514 ".dbmd-pull-{}-{nonce}-{}",
10515 std::process::id(),
10516 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
10517 );
10518 let temp = c_name(temp_name.as_bytes(), path)?;
10519 let fd = unsafe {
10520 libc::openat(
10521 directory.as_raw_fd(),
10522 temp.as_ptr(),
10523 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10524 0o600,
10525 )
10526 };
10527 if fd < 0 {
10528 return Err(std::io::Error::last_os_error().into());
10529 }
10530 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
10531 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
10532 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10533 return Err(error.into());
10534 }
10535 drop(file);
10536 let renamed = unsafe {
10537 libc::renameat(
10538 directory.as_raw_fd(),
10539 temp.as_ptr(),
10540 directory.as_raw_fd(),
10541 leaf_name.as_ptr(),
10542 )
10543 };
10544 if renamed != 0 {
10545 let error = std::io::Error::last_os_error();
10546 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10547 return Err(error.into());
10548 }
10549 directory.sync_all()?;
10550 }
10551 root.sync_all()?;
10552 Ok(())
10553}
10554
10555#[cfg(unix)]
10556fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
10557 use std::os::fd::{AsRawFd as _, FromRawFd as _};
10558
10559 let path = &entry.path;
10560 let components: Vec<&str> = path.split('/').collect();
10561 let (leaf, parents) = components
10562 .split_last()
10563 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10564 let mut directory = root.try_clone()?;
10565 for component in parents {
10566 let name = c_name(component.as_bytes(), path)?;
10567 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10568 if made != 0 {
10569 let error = std::io::Error::last_os_error();
10570 if error.raw_os_error() != Some(libc::EEXIST) {
10571 return Err(error.into());
10572 }
10573 }
10574 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10575 }
10576 let leaf_name = c_name(leaf.as_bytes(), path)?;
10577 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
10578 if unsafe {
10579 libc::fstatat(
10580 directory.as_raw_fd(),
10581 leaf_name.as_ptr(),
10582 &mut existing,
10583 libc::AT_SYMLINK_NOFOLLOW,
10584 )
10585 } == 0
10586 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
10587 {
10588 return Err(LinkError::UnsafePath { path: path.clone() });
10589 }
10590 let nonce = SystemTime::now()
10591 .duration_since(UNIX_EPOCH)
10592 .unwrap_or_default()
10593 .as_nanos();
10594 let temp_name = format!(
10595 ".dbmd-pull-{}-{nonce}-{}",
10596 std::process::id(),
10597 content_sha256(path.as_bytes())
10598 );
10599 let temp = c_name(temp_name.as_bytes(), path)?;
10600 let fd = unsafe {
10601 libc::openat(
10602 directory.as_raw_fd(),
10603 temp.as_ptr(),
10604 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10605 0o600,
10606 )
10607 };
10608 if fd < 0 {
10609 return Err(std::io::Error::last_os_error().into());
10610 }
10611 let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
10612 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
10613 let mut digest = Sha256::new();
10614 let mut total = 0_u64;
10615 let mut buffer = [0_u8; 64 * 1024];
10616 let copied = (|| -> std::io::Result<()> {
10617 loop {
10618 let read = input.read(&mut buffer)?;
10619 if read == 0 {
10620 break;
10621 }
10622 total = total.saturating_add(read as u64);
10623 if total > entry.bytes {
10624 return Err(std::io::Error::new(
10625 std::io::ErrorKind::InvalidData,
10626 "staged sync source grew beyond its verified length",
10627 ));
10628 }
10629 digest.update(&buffer[..read]);
10630 output.write_all(&buffer[..read])?;
10631 }
10632 Ok(())
10633 })();
10634 if let Err(error) = copied {
10635 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10636 return Err(error.into());
10637 }
10638 drop(output);
10639 if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
10640 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10641 return Err(invalid_feed(
10642 "private staged sync source failed final integrity verification",
10643 ));
10644 }
10645 if unsafe {
10646 libc::renameat(
10647 directory.as_raw_fd(),
10648 temp.as_ptr(),
10649 directory.as_raw_fd(),
10650 leaf_name.as_ptr(),
10651 )
10652 } != 0
10653 {
10654 let error = std::io::Error::last_os_error();
10655 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10656 return Err(error.into());
10657 }
10658 Ok(())
10659}
10660
10661#[cfg(unix)]
10662fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
10663 use std::os::fd::{AsRawFd as _, FromRawFd as _};
10664
10665 let path = &entry.path;
10666 let components: Vec<&str> = path.split('/').collect();
10667 let (leaf, parents) = components
10668 .split_last()
10669 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10670 let mut directory = root.try_clone()?;
10671 for component in parents {
10672 directory = open_dir_at(
10673 directory.as_raw_fd(),
10674 &c_name(component.as_bytes(), path)?,
10675 path,
10676 )?;
10677 }
10678 let leaf = c_name(leaf.as_bytes(), path)?;
10679 let fd = unsafe {
10680 libc::openat(
10681 directory.as_raw_fd(),
10682 leaf.as_ptr(),
10683 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10684 )
10685 };
10686 if fd < 0 {
10687 return Err(std::io::Error::last_os_error().into());
10688 }
10689 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
10690 if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
10691 return Err(invalid_feed(
10692 "private pull stage changed before its durability barrier",
10693 ));
10694 }
10695 file.sync_all()?;
10696 Ok(())
10697}
10698
10699#[cfg(unix)]
10700fn run_pull_source_workers(
10701 root: &std::fs::File,
10702 entries: &[V2StagedFile],
10703 operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
10704) -> LinkResult<()> {
10705 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10706
10707 let next = AtomicUsize::new(0);
10708 let failed = AtomicBool::new(false);
10709 let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
10710 let mut first_error = None;
10711 std::thread::scope(|scope| {
10712 let (sender, receiver) = std::sync::mpsc::channel();
10713 for _ in 0..worker_count {
10714 let sender = sender.clone();
10715 let next = &next;
10716 let failed = &failed;
10717 scope.spawn(move || {
10718 while !failed.load(Ordering::Acquire) {
10719 let index = next.fetch_add(1, Ordering::Relaxed);
10720 let Some(entry) = entries.get(index) else {
10721 break;
10722 };
10723 let result = operation(root, entry);
10724 if result.is_err() {
10725 failed.store(true, Ordering::Release);
10726 }
10727 if sender.send(result).is_err() {
10728 break;
10729 }
10730 }
10731 });
10732 }
10733 drop(sender);
10734 for result in receiver {
10735 if let Err(error) = result {
10736 if first_error.is_none() {
10737 first_error = Some(error);
10738 }
10739 }
10740 }
10741 });
10742 if let Some(error) = first_error {
10743 return Err(error);
10744 }
10745 if next.load(Ordering::Relaxed) < entries.len() {
10746 return Err(invalid_feed(
10747 "a bounded pull worker stopped before reporting every file",
10748 ));
10749 }
10750 Ok(())
10751}
10752
10753#[cfg(unix)]
10754fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
10755 use std::os::fd::AsRawFd as _;
10756
10757 for name in directory_entry_names(root)? {
10758 if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
10759 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
10760 let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
10761 sync_pull_directory_tree(&child, &child_display)?;
10762 }
10763 }
10764 root.sync_all()?;
10765 Ok(())
10766}
10767
10768#[cfg(unix)]
10769fn write_pull_sources_beneath_dir(
10770 root: &std::fs::File,
10771 entries: &[V2StagedFile],
10772) -> LinkResult<()> {
10773 run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
10780 run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
10781 sync_pull_directory_tree(root, "v2 pull stage")
10782}
10783
10784#[cfg(unix)]
10785fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
10786 use std::os::fd::AsRawFd as _;
10787 for path in paths {
10788 if !safe_store_rel_path(path) {
10789 return Err(LinkError::UnsafePath { path: path.clone() });
10790 }
10791 let components = path.split('/').collect::<Vec<_>>();
10792 let Some((leaf, parents)) = components.split_last() else {
10793 return Err(LinkError::UnsafePath { path: path.clone() });
10794 };
10795 let mut directory = root.try_clone()?;
10796 let mut missing = false;
10797 for component in parents {
10798 let name = c_name(component.as_bytes(), path)?;
10799 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
10800 None => {
10801 missing = true;
10802 break;
10803 }
10804 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
10805 Some(true) => {
10806 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10807 }
10808 }
10809 }
10810 if missing {
10811 continue;
10812 }
10813 let leaf = c_name(leaf.as_bytes(), path)?;
10814 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
10815 None => {}
10816 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
10817 Some(false) => {
10818 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
10819 return Err(std::io::Error::last_os_error().into());
10820 }
10821 directory.sync_all()?;
10822 }
10823 }
10824 }
10825 Ok(())
10826}
10827
10828#[cfg(unix)]
10829fn install_pulled_delta(
10830 dest: &Path,
10831 entries: &[(String, Vec<u8>)],
10832 deleted: &[String],
10833 rebuild_indexes: bool,
10834) -> LinkResult<()> {
10835 use ring::rand::SecureRandom as _;
10836 use std::os::fd::AsRawFd as _;
10837 use std::os::unix::ffi::OsStrExt as _;
10838
10839 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10840 let name = dest
10841 .file_name()
10842 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10843 .ok_or_else(|| LinkError::UnsafePath {
10844 path: dest.display().to_string(),
10845 })?;
10846 let parent_dir = open_or_create_dir_nofollow(parent)?;
10847 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10848 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10849 None => false,
10850 Some(true) => true,
10851 Some(false) => {
10852 return Err(LinkError::UnsafePath {
10853 path: dest.display().to_string(),
10854 });
10855 }
10856 };
10857
10858 let mut nonce = [0_u8; 16];
10859 ring::rand::SystemRandom::new()
10860 .fill(&mut nonce)
10861 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10862 let stage_label = format!(
10863 ".{}.dbmd-pull-stage-{}",
10864 name.to_string_lossy(),
10865 URL_SAFE_NO_PAD.encode(nonce)
10866 );
10867 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10868 let stage_dir = create_dir_exclusive_at(
10869 parent_dir.as_raw_fd(),
10870 &stage_name,
10871 &dest.display().to_string(),
10872 )?;
10873
10874 let prepared = (|| -> LinkResult<()> {
10875 if dest_exists {
10876 let live = open_dir_at(
10877 parent_dir.as_raw_fd(),
10878 &dest_name,
10879 &dest.display().to_string(),
10880 )?;
10881 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10882 }
10883 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10884 write_pull_entries_beneath_dir(&stage_dir, entries)?;
10885 if rebuild_indexes {
10886 let stage_store =
10887 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10888 .map_err(|error| LinkError::InvalidPack {
10889 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10890 })?;
10891 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10892 LinkError::InvalidPack {
10893 message: format!("could not materialize v2 local catalogs: {error}"),
10894 }
10895 })?;
10896 }
10897 stage_dir.sync_all()?;
10898 Ok(())
10899 })();
10900 if let Err(error) = prepared {
10901 let _ = remove_tree_at(
10902 parent_dir.as_raw_fd(),
10903 &stage_name,
10904 &dest.display().to_string(),
10905 );
10906 return Err(error);
10907 }
10908
10909 if let Err(error) =
10910 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10911 {
10912 let _ = remove_tree_at(
10913 parent_dir.as_raw_fd(),
10914 &stage_name,
10915 &dest.display().to_string(),
10916 );
10917 return Err(error);
10918 }
10919 parent_dir.sync_all()?;
10920 if dest_exists {
10921 let _ = remove_tree_at(
10925 parent_dir.as_raw_fd(),
10926 &stage_name,
10927 &dest.display().to_string(),
10928 );
10929 let _ = parent_dir.sync_all();
10930 }
10931 Ok(())
10932}
10933
10934#[cfg(unix)]
10935fn install_pulled_delta_sources(
10936 dest: &Path,
10937 entries: &[V2StagedFile],
10938 deleted: &[String],
10939 rebuild_indexes: bool,
10940 _previous: Option<&V2SyncBaseline>,
10941 _next: &V2VerifiedHead,
10942) -> LinkResult<()> {
10943 use ring::rand::SecureRandom as _;
10944 use std::os::fd::AsRawFd as _;
10945 use std::os::unix::ffi::OsStrExt as _;
10946
10947 if let Ok(store) = Store::open_strict(dest) {
10951 return install_established_v2_delta(
10952 store,
10953 entries,
10954 deleted,
10955 rebuild_indexes,
10956 _previous,
10957 _next,
10958 );
10959 }
10960
10961 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10962 let name = dest
10963 .file_name()
10964 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10965 .ok_or_else(|| LinkError::UnsafePath {
10966 path: dest.display().to_string(),
10967 })?;
10968 let parent_dir = open_or_create_dir_nofollow(parent)?;
10969 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10970 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10971 None => false,
10972 Some(true) => true,
10973 Some(false) => {
10974 return Err(LinkError::UnsafePath {
10975 path: dest.display().to_string(),
10976 })
10977 }
10978 };
10979 let mut nonce = [0_u8; 16];
10980 ring::rand::SystemRandom::new()
10981 .fill(&mut nonce)
10982 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10983 let stage_label = format!(
10984 ".{}.dbmd-pull-stage-{}",
10985 name.to_string_lossy(),
10986 URL_SAFE_NO_PAD.encode(nonce)
10987 );
10988 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10989 let stage_dir = create_dir_exclusive_at(
10990 parent_dir.as_raw_fd(),
10991 &stage_name,
10992 &dest.display().to_string(),
10993 )?;
10994 let prepared = (|| -> LinkResult<()> {
10995 if dest_exists {
10996 let live = open_dir_at(
10997 parent_dir.as_raw_fd(),
10998 &dest_name,
10999 &dest.display().to_string(),
11000 )?;
11001 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
11002 }
11003 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
11004 write_pull_sources_beneath_dir(&stage_dir, entries)?;
11005 if rebuild_indexes {
11006 let stage_store =
11007 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
11008 .map_err(|error| LinkError::InvalidPack {
11009 message: format!("v2 staging tree is not a valid db.md store: {error}"),
11010 })?;
11011 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
11012 LinkError::InvalidPack {
11013 message: format!("could not materialize v2 local catalogs: {error}"),
11014 }
11015 })?;
11016 }
11017 stage_dir.sync_all()?;
11018 Ok(())
11019 })();
11020 if let Err(error) = prepared {
11021 let _ = remove_tree_at(
11022 parent_dir.as_raw_fd(),
11023 &stage_name,
11024 &dest.display().to_string(),
11025 );
11026 return Err(error);
11027 }
11028 if let Err(error) =
11029 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
11030 {
11031 let _ = remove_tree_at(
11032 parent_dir.as_raw_fd(),
11033 &stage_name,
11034 &dest.display().to_string(),
11035 );
11036 return Err(error);
11037 }
11038 parent_dir.sync_all()?;
11039 if dest_exists {
11040 let _ = remove_tree_at(
11041 parent_dir.as_raw_fd(),
11042 &stage_name,
11043 &dest.display().to_string(),
11044 );
11045 let _ = parent_dir.sync_all();
11046 }
11047 Ok(())
11048}
11049
11050#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
11051struct V2PullCoordinate {
11052 head_seq: Option<u64>,
11053 commit_hash: Option<String>,
11054 view_kind: Option<String>,
11055 view_revision: Option<String>,
11056}
11057
11058#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
11059struct V2PullFileCoordinate {
11060 sha256: String,
11061 bytes: u64,
11062}
11063
11064#[derive(Debug, Clone, Deserialize, Serialize)]
11065struct V2PullJournalEntry {
11066 path: String,
11067 old: Option<V2PullFileCoordinate>,
11068 new: Option<V2PullFileCoordinate>,
11069 backup: Option<String>,
11070}
11071
11072#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
11073#[serde(rename_all = "snake_case")]
11074enum V2PullPhase {
11075 Preparing,
11076 Ready,
11077}
11078
11079#[derive(Debug, Clone, Deserialize, Serialize)]
11080struct V2PullJournal {
11081 v: u8,
11082 phase: V2PullPhase,
11083 brain: String,
11084 previous: V2PullCoordinate,
11085 next: V2PullCoordinate,
11086 backup_dir: String,
11087 entries: Vec<V2PullJournalEntry>,
11088}
11089
11090const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
11091
11092fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
11093 V2PullCoordinate {
11094 head_seq: baseline.and_then(|value| value.head_seq),
11095 commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
11096 view_kind: baseline.and_then(|value| value.view_kind.clone()),
11097 view_revision: baseline.and_then(|value| value.view_revision.clone()),
11098 }
11099}
11100
11101fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
11102 V2PullCoordinate {
11103 head_seq: head.pointer.as_ref().map(|value| value.seq),
11104 commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
11105 view_kind: Some(head.view_kind.clone()),
11106 view_revision: Some(head.view_revision.clone()),
11107 }
11108}
11109
11110fn v2_pull_file_coordinate(
11111 store: &Store,
11112 path: &str,
11113 limit: u64,
11114) -> LinkResult<Option<V2PullFileCoordinate>> {
11115 let file = match store.open_regular(Path::new(path)) {
11116 Ok(file) => file,
11117 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11118 Err(error) => return Err(error.into()),
11119 };
11120 let bytes = file.metadata()?.len();
11121 if bytes > limit || bytes > MAX_PULL_TRANSACTION_BYTES {
11122 return Err(invalid_feed(
11123 "pull transaction file exceeds its declared bound",
11124 ));
11125 }
11126 Ok(Some(V2PullFileCoordinate {
11127 sha256: content_sha256_reader(file)?,
11128 bytes,
11129 }))
11130}
11131
11132fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
11133 let mut bytes = serde_json::to_vec_pretty(journal)
11134 .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
11135 bytes.push(b'\n');
11136 Ok(bytes)
11137}
11138
11139fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
11140 let backup_prefix = ".dbmd/pull-backup-";
11141 let suffix = journal
11142 .backup_dir
11143 .strip_prefix(backup_prefix)
11144 .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
11145 let mut paths = std::collections::BTreeSet::new();
11146 if journal.v != 1
11147 || !crate::ulid::is_ulid(&journal.brain)
11148 || !crate::ulid::is_ulid(suffix)
11149 || journal.entries.is_empty()
11150 || journal.entries.len() > MAX_PUSH_FILES + 4
11151 || journal.previous == journal.next
11152 {
11153 return Err(invalid_feed("v2 pull journal failed validation"));
11154 }
11155 for (index, entry) in journal.entries.iter().enumerate() {
11156 if !safe_store_rel_path(&entry.path)
11157 || entry.path == V2_PULL_JOURNAL
11158 || entry.path.starts_with(backup_prefix)
11159 {
11160 return Err(invalid_feed(format!(
11161 "v2 pull journal has an unsafe entry path: {}",
11162 entry.path
11163 )));
11164 }
11165 if !paths.insert(entry.path.clone()) {
11166 return Err(invalid_feed(format!(
11167 "v2 pull journal repeats entry path: {}",
11168 entry.path
11169 )));
11170 }
11171 if entry.old.is_none() && entry.new.is_none() {
11172 return Err(invalid_feed(format!(
11173 "v2 pull journal entry has no coordinate: {}",
11174 entry.path
11175 )));
11176 }
11177 if entry
11178 .old
11179 .iter()
11180 .chain(entry.new.iter())
11181 .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_PULL_TRANSACTION_BYTES)
11182 {
11183 return Err(invalid_feed(format!(
11184 "v2 pull journal entry has invalid content metadata: {}",
11185 entry.path
11186 )));
11187 }
11188 if entry.backup.as_deref()
11189 != entry
11190 .old
11191 .as_ref()
11192 .map(|_| format!("{index:08x}"))
11193 .as_deref()
11194 {
11195 return Err(invalid_feed(format!(
11196 "v2 pull journal entry has an invalid backup coordinate: {}",
11197 entry.path
11198 )));
11199 }
11200 }
11201 Ok(())
11202}
11203
11204fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
11205 #[cfg(unix)]
11206 {
11207 use std::os::unix::fs::PermissionsExt as _;
11208 match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
11209 Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
11210 return Err(invalid_feed(
11211 "v2 pull journal is accessible to group/other; set mode 0600",
11212 ));
11213 }
11214 Ok(_) => {}
11215 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11216 Err(error) => return Err(error.into()),
11217 }
11218 }
11219 let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
11220 Ok(bytes) => bytes,
11221 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11222 Err(error) => return Err(error.into()),
11223 };
11224 let journal: V2PullJournal =
11225 serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
11226 validate_v2_pull_journal(&journal)?;
11227 Ok(Some(journal))
11228}
11229
11230fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
11231 match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
11235 Ok(()) => {}
11236 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
11237 Err(error) => return Err(error.into()),
11238 }
11239 match store.remove_private_tree(Path::new(&journal.backup_dir)) {
11240 Ok(()) => Ok(()),
11241 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
11242 Err(error) => Err(error.into()),
11243 }
11244}
11245
11246fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
11247 let names = match store.directory_names(Path::new(".dbmd")) {
11248 Ok(names) => names,
11249 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
11250 Err(error) => return Err(error.into()),
11251 };
11252 for name in names {
11253 let Some(name) = name.to_str() else {
11254 continue;
11255 };
11256 let Some(suffix) = name.strip_prefix("pull-backup-") else {
11257 continue;
11258 };
11259 if crate::ulid::is_ulid(suffix) {
11260 store.remove_private_tree(&Path::new(".dbmd").join(name))?;
11261 }
11262 }
11263 Ok(())
11264}
11265
11266fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
11267 for entry in &journal.entries {
11269 let limit = entry
11270 .old
11271 .as_ref()
11272 .into_iter()
11273 .chain(entry.new.iter())
11274 .map(|value| value.bytes)
11275 .max()
11276 .unwrap_or(0);
11277 let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
11278 if current != entry.old && current != entry.new {
11279 return Err(LinkError::InvalidPack {
11280 message: format!(
11281 "cannot recover interrupted pull because `{}` changed afterward",
11282 entry.path
11283 ),
11284 });
11285 }
11286 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
11287 let path = Path::new(&journal.backup_dir).join(backup);
11288 let file = store.open_regular(&path)?;
11289 if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
11290 return Err(invalid_feed("v2 pull recovery backup failed verification"));
11291 }
11292 }
11293 }
11294 for entry in journal.entries.iter().rev() {
11295 match (&entry.old, &entry.backup) {
11296 (Some(old), Some(backup)) => {
11297 let bytes =
11298 store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
11299 store.write_atomic(Path::new(&entry.path), &bytes)?;
11300 }
11301 (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
11302 store.remove_file(Path::new(&entry.path))?;
11303 }
11304 (None, None) => {}
11305 _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
11306 }
11307 }
11308 crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
11309 message: format!("could not rebuild catalogs after pull recovery: {error}"),
11310 })?;
11311 cleanup_v2_pull_journal(store, journal)
11312}
11313
11314fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
11315 let Ok(store) = Store::open_strict(dest) else {
11316 return Ok(());
11317 };
11318 if let Some(journal) = load_v2_pull_journal(&store)? {
11319 if journal.brain != brain {
11320 return Err(invalid_feed("v2 pull journal belongs to another brain"));
11321 }
11322 if journal.phase == V2PullPhase::Preparing {
11323 cleanup_v2_pull_journal(&store, &journal)?;
11324 } else {
11325 let baseline = load_v2_baseline(cfg, brain, dest)?;
11326 let current = v2_pull_baseline_coordinate(baseline.as_ref());
11327 if current == journal.next {
11328 cleanup_v2_pull_journal(&store, &journal)?;
11329 } else {
11330 if current != journal.previous {
11331 return Err(invalid_feed(
11332 "cannot recover interrupted pull because its baseline changed afterward",
11333 ));
11334 }
11335 rollback_v2_pull(&store, &journal)?;
11336 }
11337 }
11338 }
11339 prune_orphan_v2_pull_backups(&store)
11344}
11345
11346fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
11347 let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
11348 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
11349 })?;
11350 if let Some(journal) = load_v2_pull_journal(&store)? {
11351 cleanup_v2_pull_journal(&store, &journal)?;
11352 }
11353 Ok(())
11354}
11355
11356#[cfg(windows)]
11357fn install_windows_initial_sources(
11358 dest: &Path,
11359 entries: &[V2StagedFile],
11360 rebuild_indexes: bool,
11361) -> LinkResult<()> {
11362 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
11363 let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
11364 path: dest.display().to_string(),
11365 })?;
11366 let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
11367 if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
11368 return Err(LinkError::UnsafePath {
11369 path: dest.display().to_string(),
11370 });
11371 }
11372 let stage_name = format!(
11373 ".{}.dbmd-pull-stage-{}",
11374 name.to_string_lossy(),
11375 crate::ulid::mint()
11376 );
11377 let stage_path = parent.join(&stage_name);
11378 let stage_capability =
11379 crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
11380 let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
11381 let prepared = (|| -> LinkResult<()> {
11382 for entry in entries {
11383 let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
11384 if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
11385 return Err(invalid_feed(
11386 "private staged sync source failed final integrity verification",
11387 ));
11388 }
11389 stage.write_atomic(Path::new(&entry.path), &bytes)?;
11390 }
11391 let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
11392 .map_err(|error| LinkError::InvalidPack {
11393 message: format!("v2 staging tree is not a valid db.md store: {error}"),
11394 })?;
11395 if rebuild_indexes {
11396 crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
11397 message: format!("could not materialize v2 local catalogs: {error}"),
11398 })?;
11399 }
11400 Ok(())
11401 })();
11402 if let Err(error) = prepared {
11403 let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
11404 return Err(error);
11405 }
11406 crate::fsx::rename_directory_beneath(
11407 &parent_capability,
11408 Path::new(&stage_name),
11409 Path::new(name),
11410 )?;
11411 Ok(())
11412}
11413
11414fn install_established_v2_delta(
11415 store: Store,
11416 entries: &[V2StagedFile],
11417 deleted: &[String],
11418 rebuild_indexes: bool,
11419 previous: Option<&V2SyncBaseline>,
11420 next: &V2VerifiedHead,
11421) -> LinkResult<()> {
11422 if load_v2_pull_journal(&store)?.is_some() {
11423 return Err(invalid_feed(
11424 "an interrupted pull must be recovered before installing",
11425 ));
11426 }
11427 let mut sources = std::collections::BTreeMap::new();
11428 for entry in entries {
11429 if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
11430 return Err(invalid_feed("pull mutation repeats a path"));
11431 }
11432 }
11433 let mut paths = sources.keys().cloned().collect::<Vec<_>>();
11434 paths.extend(deleted.iter().cloned());
11435 paths.sort();
11436 paths.dedup();
11437 if paths.is_empty() {
11438 return Ok(());
11439 }
11440 let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
11441 let mut journal = V2PullJournal {
11442 v: 1,
11443 phase: V2PullPhase::Preparing,
11444 brain: next.brain_id.clone(),
11445 previous: v2_pull_baseline_coordinate(previous),
11446 next: v2_pull_head_coordinate(next),
11447 backup_dir: backup_dir.clone(),
11448 entries: Vec::with_capacity(paths.len()),
11449 };
11450 for path in &paths {
11451 let old = v2_pull_file_coordinate(&store, path, MAX_PULL_TRANSACTION_BYTES)?;
11452 let new = sources.get(path).map(|entry| V2PullFileCoordinate {
11453 sha256: entry.sha256.clone(),
11454 bytes: entry.bytes,
11455 });
11456 if old == new {
11457 continue;
11458 }
11459 let index = journal.entries.len();
11460 journal.entries.push(V2PullJournalEntry {
11461 path: path.clone(),
11462 backup: old.as_ref().map(|_| format!("{index:08x}")),
11463 old,
11464 new,
11465 });
11466 }
11467 if journal.entries.is_empty() {
11468 return Ok(());
11469 }
11470 let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
11471 entry
11472 .old
11473 .as_ref()
11474 .map_or(Some(total), |old| total.checked_add(old.bytes))
11475 });
11476 if backup_bytes.is_none_or(|bytes| bytes > MAX_PULL_TRANSACTION_BYTES) {
11477 return Err(LinkError::InvalidPack {
11478 message: "pull recovery preimages exceed the bounded transaction limit".to_string(),
11479 });
11480 }
11481 validate_v2_pull_journal(&journal)?;
11482 store.write_private_atomic_new(
11483 Path::new(V2_PULL_JOURNAL),
11484 &v2_pull_journal_bytes(&journal)?,
11485 )?;
11486 let prepared = (|| -> LinkResult<()> {
11487 store.create_private_dir_all(Path::new(&backup_dir))?;
11488 for entry in &journal.entries {
11489 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
11490 let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
11491 if content_sha256(&bytes) != old.sha256 {
11492 return Err(invalid_feed("live pull source changed during backup"));
11493 }
11494 store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
11495 }
11496 }
11497 journal.phase = V2PullPhase::Ready;
11498 store.write_private_atomic(
11499 Path::new(V2_PULL_JOURNAL),
11500 &v2_pull_journal_bytes(&journal)?,
11501 )?;
11502 Ok(())
11503 })();
11504 if let Err(error) = prepared {
11505 let cleanup = cleanup_v2_pull_journal(&store, &journal);
11506 return match cleanup {
11507 Ok(()) => Err(error),
11508 Err(cleanup) => Err(LinkError::InvalidPack {
11509 message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
11510 }),
11511 };
11512 }
11513 let installed = (|| -> LinkResult<()> {
11514 for entry in &journal.entries {
11515 if v2_pull_file_coordinate(&store, &entry.path, MAX_PULL_TRANSACTION_BYTES)?
11516 != entry.old
11517 {
11518 return Err(LinkError::InvalidPack {
11519 message: format!("local path `{}` changed during pull", entry.path),
11520 });
11521 }
11522 if let Some(source) = sources.get(&entry.path) {
11523 let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
11524 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
11525 return Err(invalid_feed(
11526 "private staged sync source failed final integrity verification",
11527 ));
11528 }
11529 store.write_atomic(Path::new(&entry.path), &bytes)?;
11530 } else if entry.old.is_some() {
11531 store.remove_file(Path::new(&entry.path))?;
11532 }
11533 }
11534 if rebuild_indexes {
11535 crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
11536 message: format!("could not materialize v2 local catalogs: {error}"),
11537 })?;
11538 }
11539 Ok(())
11540 })();
11541 if let Err(error) = installed {
11542 return match rollback_v2_pull(&store, &journal) {
11543 Ok(()) => Err(error),
11544 Err(rollback) => Err(LinkError::InvalidPack {
11545 message: format!("{error}; durable pull rollback also failed: {rollback}"),
11546 }),
11547 };
11548 }
11549 Ok(())
11550}
11551
11552#[cfg(windows)]
11553fn install_pulled_delta_sources(
11554 dest: &Path,
11555 entries: &[V2StagedFile],
11556 deleted: &[String],
11557 rebuild_indexes: bool,
11558 previous: Option<&V2SyncBaseline>,
11559 next: &V2VerifiedHead,
11560) -> LinkResult<()> {
11561 match Store::open_strict(dest) {
11562 Ok(store) => {
11563 install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
11564 }
11565 Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
11566 }
11567}
11568
11569#[cfg(not(any(unix, windows)))]
11570fn install_pulled_delta_sources(
11571 _dest: &Path,
11572 _entries: &[V2StagedFile],
11573 _deleted: &[String],
11574 _rebuild_indexes: bool,
11575 _previous: Option<&V2SyncBaseline>,
11576 _next: &V2VerifiedHead,
11577) -> LinkResult<()> {
11578 Err(LinkError::UnsupportedPlatform {
11579 operation: "atomic v2 pull install",
11580 })
11581}
11582
11583#[cfg(unix)]
11584fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
11585 install_pulled_delta(dest, entries, &[], false)
11586}
11587
11588#[cfg(not(windows))]
11589fn is_safe_slug(slug: &str) -> bool {
11590 !slug.is_empty()
11591 && slug.len() <= 63
11592 && !slug.starts_with('-')
11593 && !slug.ends_with('-')
11594 && slug
11595 .bytes()
11596 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
11597}
11598
11599fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
11600 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
11601}
11602
11603fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
11604 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
11605}
11606
11607fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
11608 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
11609}
11610
11611fn preflight_zip_central_directory(
11612 bytes: &[u8],
11613 offset: usize,
11614 size: usize,
11615 count: u64,
11616) -> LinkResult<()> {
11617 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
11618 let end = offset
11619 .checked_add(size)
11620 .filter(|end| *end <= bytes.len())
11621 .ok_or_else(|| LinkError::InvalidPack {
11622 message: "ZIP central directory is out of bounds".to_string(),
11623 })?;
11624 let mut cursor = offset;
11625 for _ in 0..count {
11626 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
11627 return Err(LinkError::InvalidPack {
11628 message: "ZIP central directory entry count is inconsistent".to_string(),
11629 });
11630 }
11631 if le_u16(bytes, cursor + 34) != Some(0) {
11632 return Err(LinkError::InvalidPack {
11633 message: "multi-disk ZIP archives are not supported".to_string(),
11634 });
11635 }
11636 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
11637 total.checked_add(le_u16(bytes, cursor + at)? as usize)
11638 });
11639 cursor = cursor
11640 .checked_add(46)
11641 .and_then(|fixed| fixed.checked_add(variable?))
11642 .filter(|cursor| *cursor <= end)
11643 .ok_or_else(|| LinkError::InvalidPack {
11644 message: "ZIP central directory entry is truncated".to_string(),
11645 })?;
11646 }
11647 if cursor != end {
11648 return Err(LinkError::InvalidPack {
11649 message: "ZIP central directory size is inconsistent".to_string(),
11650 });
11651 }
11652 Ok(())
11653}
11654
11655fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
11659 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
11660 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
11661 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
11662 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
11663 let eocd = bytes[search_start..]
11664 .windows(4)
11665 .rposition(|window| window == EOCD_SIG)
11666 .map(|offset| search_start + offset)
11667 .ok_or_else(|| LinkError::InvalidPack {
11668 message: "ZIP has no end-of-central-directory record".to_string(),
11669 })?;
11670 let invalid_end = || LinkError::InvalidPack {
11671 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
11672 };
11673 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
11674 if eocd
11675 .checked_add(22)
11676 .and_then(|end| end.checked_add(comment_len))
11677 != Some(bytes.len())
11678 {
11679 return Err(invalid_end());
11683 }
11684 let disk = le_u16(bytes, eocd + 4);
11685 let central_disk = le_u16(bytes, eocd + 6);
11686 if disk != Some(0) || central_disk != Some(0) {
11687 return Err(LinkError::InvalidPack {
11688 message: "multi-disk ZIP archives are not supported".to_string(),
11689 });
11690 }
11691 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
11692 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
11693 if entries_on_disk != ordinary {
11694 return Err(LinkError::InvalidPack {
11695 message: "multi-disk ZIP archives are not supported".to_string(),
11696 });
11697 }
11698 let zip64_locator = eocd
11699 .checked_sub(20)
11700 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
11701 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
11702 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
11703 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
11704 if central_offset
11705 .checked_add(central_size)
11706 .filter(|end| *end == eocd)
11707 .is_none()
11708 {
11709 return Err(invalid_end());
11710 }
11711 (ordinary as u64, central_offset, central_size)
11712 } else {
11713 let Some(locator) = zip64_locator else {
11714 return Err(invalid_end());
11715 };
11716 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
11717 return Err(LinkError::InvalidPack {
11718 message: "multi-disk ZIP64 archives are not supported".to_string(),
11719 });
11720 }
11721 let record = le_u64(bytes, locator + 8)
11722 .and_then(|offset| usize::try_from(offset).ok())
11723 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
11724 .ok_or_else(|| LinkError::InvalidPack {
11725 message: "ZIP64 archive has an invalid end record".to_string(),
11726 })?;
11727 let record_size = le_u64(bytes, record + 4)
11728 .and_then(|size| usize::try_from(size).ok())
11729 .filter(|size| *size >= 44)
11730 .ok_or_else(invalid_end)?;
11731 if record
11732 .checked_add(12)
11733 .and_then(|end| end.checked_add(record_size))
11734 != Some(locator)
11735 || le_u32(bytes, record + 16) != Some(0)
11736 || le_u32(bytes, record + 20) != Some(0)
11737 {
11738 return Err(invalid_end());
11739 }
11740 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
11741 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
11742 let central_size = le_u64(bytes, record + 40)
11743 .and_then(|size| usize::try_from(size).ok())
11744 .ok_or_else(invalid_end)?;
11745 let central_offset = le_u64(bytes, record + 48)
11746 .and_then(|offset| usize::try_from(offset).ok())
11747 .ok_or_else(invalid_end)?;
11748 if zip64_on_disk != zip64_total
11749 || central_offset
11750 .checked_add(central_size)
11751 .filter(|end| *end == record)
11752 .is_none()
11753 {
11754 return Err(invalid_end());
11755 }
11756 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
11757 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
11758 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
11759 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
11760 {
11761 return Err(invalid_end());
11762 }
11763 (zip64_total, central_offset, central_size)
11764 };
11765 if count == 0 || count > max_entries as u64 {
11766 return Err(LinkError::InvalidPack {
11767 message: format!("invalid file count {count}"),
11768 });
11769 }
11770 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
11771 Ok(())
11772}
11773
11774fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
11775 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
11776 let mut archive =
11777 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
11778 message: format!("ZIP parse failed: {err}"),
11779 })?;
11780 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
11781 return Err(LinkError::InvalidPack {
11782 message: format!("invalid file count {}", archive.len()),
11783 });
11784 }
11785 let mut total = 0u64;
11786 let mut seen = std::collections::HashSet::new();
11787 let mut entries = Vec::with_capacity(archive.len());
11788 for index in 0..archive.len() {
11789 let mut file = archive
11790 .by_index(index)
11791 .map_err(|err| LinkError::InvalidPack {
11792 message: format!("ZIP entry failed: {err}"),
11793 })?;
11794 if file.is_dir() {
11795 continue;
11796 }
11797 let path = file.name().to_string();
11798 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
11799 return Err(LinkError::UnsafePath { path });
11800 }
11801 if file
11802 .unix_mode()
11803 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
11804 {
11805 return Err(LinkError::InvalidPack {
11806 message: format!("non-file entry `{path}`"),
11807 });
11808 }
11809 if !seen.insert(path.clone()) {
11810 return Err(LinkError::InvalidPack {
11811 message: format!("duplicate path `{path}`"),
11812 });
11813 }
11814 let remaining = MAX_STORE_BYTES.saturating_sub(total);
11815 if file.size() > remaining {
11816 return Err(LinkError::InvalidPack {
11817 message: "expanded content exceeds the 512 MB limit".to_string(),
11818 });
11819 }
11820 let mut content = Vec::new();
11821 (&mut file)
11822 .take(remaining + 1)
11823 .read_to_end(&mut content)
11824 .map_err(|err| LinkError::InvalidPack {
11825 message: format!("could not decompress `{path}`: {err}"),
11826 })?;
11827 if content.len() as u64 > remaining {
11828 return Err(LinkError::InvalidPack {
11829 message: "expanded content exceeds the 512 MB limit".to_string(),
11830 });
11831 }
11832 if content.len() as u64 != file.size() {
11833 return Err(LinkError::InvalidPack {
11834 message: format!("length mismatch for `{path}`"),
11835 });
11836 }
11837 total += content.len() as u64;
11838 entries.push((path, content));
11839 }
11840 if entries.is_empty() {
11841 return Err(LinkError::InvalidPack {
11842 message: "pack contains no files".to_string(),
11843 });
11844 }
11845 Ok(entries)
11846}
11847
11848fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
11849 let mut expected = std::collections::BTreeMap::new();
11850 for file in signed {
11851 if !safe_store_rel_path(&file.path) {
11852 return Err(LinkError::UnsafePath {
11853 path: file.path.clone(),
11854 });
11855 }
11856 if !is_sha256(&file.sha256)
11857 || expected
11858 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11859 .is_some()
11860 {
11861 return Err(invalid_feed(
11862 "signed snapshot manifest contains an invalid or duplicate file",
11863 ));
11864 }
11865 }
11866 if expected.len() != entries.len() {
11867 return Err(invalid_feed(
11868 "downloaded pack file set differs from the signed snapshot manifest",
11869 ));
11870 }
11871 for (path, bytes) in entries {
11872 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
11873 return Err(invalid_feed(format!(
11874 "downloaded pack contains unsigned path `{path}`"
11875 )));
11876 };
11877 if *declared_bytes != bytes.len() as u64
11878 || *sha256 != format!("{:x}", Sha256::digest(bytes))
11879 {
11880 return Err(invalid_feed(format!(
11881 "downloaded file `{path}` differs from its signed manifest"
11882 )));
11883 }
11884 }
11885 Ok(())
11886}
11887
11888pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
11894 require_hardened_filesystem("sync push")?;
11895 preflight_push_ownership(store)?;
11896 let mut out: Vec<(String, String)> = Vec::new();
11897 let mut total = 0u64;
11898
11899 let mut read_text = |rel: &str| -> LinkResult<String> {
11900 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
11901 total = total
11902 .checked_add(bytes.len() as u64)
11903 .ok_or_else(|| LinkError::PushTooLarge {
11904 detail: "uncompressed byte count overflow".to_string(),
11905 })?;
11906 if total > MAX_STORE_BYTES {
11907 return Err(LinkError::PushTooLarge {
11908 detail: format!("{total} uncompressed bytes"),
11909 });
11910 }
11911 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
11912 path: rel.to_string(),
11913 })
11914 };
11915
11916 out.push(("DB.md".to_string(), read_text("DB.md")?));
11917 if store
11918 .regular_file_exists(Path::new("assets.jsonl"))
11919 .unwrap_or(false)
11920 {
11921 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
11922 }
11923 if store
11924 .regular_file_exists(Path::new("log.md"))
11925 .unwrap_or(false)
11926 {
11927 out.push(("log.md".to_string(), read_text("log.md")?));
11928 }
11929 if store.directory_exists(Path::new("log"))? {
11930 for rel in store.walk_regular_files(Path::new("log"))? {
11931 let rel_str = rel.to_string_lossy().replace('\\', "/");
11932 if rel.extension().and_then(std::ffi::OsStr::to_str) != Some("md") {
11933 continue;
11934 }
11935 if !safe_store_rel_path(&rel_str) {
11936 return Err(LinkError::UnsafePath { path: rel_str });
11937 }
11938 let content = read_text(&rel_str)?;
11939 out.push((rel_str, content));
11940 }
11941 }
11942
11943 for rel in store.walk()? {
11944 let rel_str = rel.to_string_lossy().replace('\\', "/");
11945 if !safe_store_rel_path(&rel_str) {
11946 return Err(LinkError::UnsafePath { path: rel_str });
11949 }
11950 let content = read_text(&rel_str)?;
11951 out.push((rel_str, content));
11952 }
11953
11954 out.sort_by(|a, b| a.0.cmp(&b.0));
11955 Ok(out)
11956}
11957
11958fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
11962 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
11963 return Err(LinkError::from(std::io::Error::new(
11964 std::io::ErrorKind::PermissionDenied,
11965 format!("cannot push: nested db.md store at {}", nested.display()),
11966 )));
11967 }
11968
11969 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
11970 return Err(LinkError::from(std::io::Error::new(
11971 std::io::ErrorKind::PermissionDenied,
11972 format!(
11973 "cannot push: {} is a symlink outside the store ownership model",
11974 symlink.display()
11975 ),
11976 )));
11977 }
11978 Ok(())
11979}
11980
11981pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
11987 require_safe_ref(brain)?;
11988 let remote = verified_remote_head(cfg, brain, false)?;
11989 if files.len() > MAX_PUSH_FILES {
11990 return Err(LinkError::PushTooLarge {
11991 detail: format!("{} files", files.len()),
11992 });
11993 }
11994 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
11995 if raw_total > MAX_STORE_BYTES {
11996 return Err(LinkError::PushTooLarge {
11997 detail: format!("{raw_total} uncompressed bytes"),
11998 });
11999 }
12000
12001 if cfg.brain_key.is_none() {
12005 let body = json!({
12006 "files": files
12007 .iter()
12008 .map(|(p, c)| json!({ "path": p, "content": c }))
12009 .collect::<Vec<_>>(),
12010 });
12011 if body.to_string().len() <= MAX_PUSH_BYTES {
12012 let path = format!("/api/hub/brains/{brain}/push");
12013 let pushed = ensure_ok(
12014 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12015 "sync push",
12016 )?;
12017 return Ok(pushed);
12018 }
12019 }
12020
12021 let pack = build_store_pack(files)?;
12022 if pack.len() as u64 > MAX_PACK_BYTES {
12023 return Err(LinkError::PushTooLarge {
12024 detail: format!("{} pack bytes", pack.len()),
12025 });
12026 }
12027 let sha256 = format!("{:x}", Sha256::digest(&pack));
12028 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
12029 if let Some(key) = &cfg.brain_key {
12030 if !remote.head.verified {
12031 return Err(invalid_feed(
12032 "self-custody push requires a fully verified, unscoped feed head",
12033 ));
12034 }
12035 let identity = remote
12036 .identity
12037 .as_ref()
12038 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
12039 let current_multikey = format!("ed25519:{}", identity.fingerprint);
12040 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
12041 return Err(invalid_feed(
12042 "configured brain key is not the verified current brain identity",
12043 ));
12044 }
12045 let next_seq = remote
12048 .head
12049 .seq
12050 .checked_add(1)
12051 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
12052 let mut manifest: Vec<WireFeedFile> = files
12053 .iter()
12054 .map(|(path, content)| WireFeedFile {
12055 path: path.clone(),
12056 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
12057 bytes: content.len() as u64,
12058 })
12059 .collect();
12060 manifest.sort_by(|a, b| a.path.cmp(&b.path));
12061 let ts = crate::now()
12062 .with_timezone(&chrono::Utc)
12063 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
12064 .to_string();
12065 let entry = self_custody_entry(
12066 key,
12067 next_seq,
12068 ts,
12069 &sha256,
12070 &manifest,
12071 remote.head.feed_hash.as_deref(),
12072 )?;
12073 meta["entry"] = Value::String(entry);
12074 }
12075 let presigned = ensure_ok(
12076 request(
12077 cfg,
12078 "POST",
12079 &format!("/api/hub/brains/{brain}/packs/presign"),
12080 Some(&meta),
12081 Auth::Required,
12082 )?,
12083 "prepare pack upload",
12084 )?;
12085 let url = presigned
12086 .get("url")
12087 .and_then(Value::as_str)
12088 .ok_or_else(|| LinkError::InvalidPack {
12089 message: "the hub returned no upload URL".to_string(),
12090 })?;
12091 put_presigned(
12092 cfg,
12093 url,
12094 presigned.get("headers").unwrap_or(&Value::Null),
12095 &pack,
12096 )?;
12097 let committed = ensure_ok(
12098 request(
12099 cfg,
12100 "POST",
12101 &format!("/api/hub/brains/{brain}/packs/commit"),
12102 Some(&meta),
12103 Auth::Required,
12104 )?,
12105 "commit pack",
12106 )?;
12107 Ok(committed)
12108}
12109
12110fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
12111 const LOCAL_HEADER: u32 = 0x0403_4b50;
12112 const CENTRAL_HEADER: u32 = 0x0201_4b50;
12113 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
12114 const VERSION_20: u16 = 20;
12115 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
12116 const UTF8_FLAG: u16 = 1 << 11;
12117 const STORED: u16 = 0;
12118 const DOS_TIME_MIDNIGHT: u16 = 0;
12119 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
12120 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
12121
12122 struct CentralEntry<'a> {
12123 name: &'a [u8],
12124 crc32: u32,
12125 size: u32,
12126 local_offset: u32,
12127 }
12128
12129 fn push_u16(out: &mut Vec<u8>, value: u16) {
12130 out.extend_from_slice(&value.to_le_bytes());
12131 }
12132
12133 fn push_u32(out: &mut Vec<u8>, value: u32) {
12134 out.extend_from_slice(&value.to_le_bytes());
12135 }
12136
12137 if files.is_empty() {
12138 return Err(LinkError::InvalidPack {
12139 message: "cannot create an empty snapshot pack".to_string(),
12140 });
12141 }
12142 if files.len() > u16::MAX as usize {
12143 return Err(LinkError::PushTooLarge {
12144 detail: format!(
12145 "{} files (canonical ZIP32 packs cap at {})",
12146 files.len(),
12147 u16::MAX
12148 ),
12149 });
12150 }
12151
12152 let mut sorted: Vec<_> = files.iter().collect();
12153 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
12154 let mut previous: Option<&str> = None;
12155 for (path, content) in &sorted {
12156 if !safe_store_rel_path(path) {
12157 return Err(LinkError::UnsafePath {
12158 path: (*path).clone(),
12159 });
12160 }
12161 if previous == Some(path.as_str()) {
12162 return Err(LinkError::InvalidPack {
12163 message: format!("duplicate path `{path}`"),
12164 });
12165 }
12166 previous = Some(path.as_str());
12167 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
12168 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
12169 })?;
12170 }
12171
12172 let mut out = Vec::new();
12173 let mut central = Vec::with_capacity(sorted.len());
12174 for (path, content) in sorted {
12175 let name = path.as_bytes();
12176 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
12177 message: format!("ZIP entry name is too long: `{path}`"),
12178 })?;
12179 let bytes = content.as_bytes();
12180 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
12181 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
12182 })?;
12183 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
12184 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
12185 })?;
12186 let crc32 = crc32fast::hash(bytes);
12187
12188 push_u32(&mut out, LOCAL_HEADER);
12191 push_u16(&mut out, VERSION_20);
12192 push_u16(&mut out, UTF8_FLAG);
12193 push_u16(&mut out, STORED);
12194 push_u16(&mut out, DOS_TIME_MIDNIGHT);
12195 push_u16(&mut out, DOS_DATE_1980_01_01);
12196 push_u32(&mut out, crc32);
12197 push_u32(&mut out, size);
12198 push_u32(&mut out, size);
12199 push_u16(&mut out, name_len);
12200 push_u16(&mut out, 0); out.extend_from_slice(name);
12202 out.extend_from_slice(bytes);
12203
12204 central.push(CentralEntry {
12205 name,
12206 crc32,
12207 size,
12208 local_offset,
12209 });
12210 }
12211
12212 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
12213 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
12214 })?;
12215 for entry in ¢ral {
12216 push_u32(&mut out, CENTRAL_HEADER);
12217 push_u16(&mut out, MADE_BY_UNIX_20);
12218 push_u16(&mut out, VERSION_20);
12219 push_u16(&mut out, UTF8_FLAG);
12220 push_u16(&mut out, STORED);
12221 push_u16(&mut out, DOS_TIME_MIDNIGHT);
12222 push_u16(&mut out, DOS_DATE_1980_01_01);
12223 push_u32(&mut out, entry.crc32);
12224 push_u32(&mut out, entry.size);
12225 push_u32(&mut out, entry.size);
12226 push_u16(&mut out, entry.name.len() as u16);
12227 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);
12232 push_u32(&mut out, entry.local_offset);
12233 out.extend_from_slice(entry.name);
12234 }
12235 let central_size = u32::try_from(out.len())
12236 .ok()
12237 .and_then(|end| end.checked_sub(central_offset))
12238 .ok_or_else(|| LinkError::PushTooLarge {
12239 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
12240 })?;
12241 let entry_count = central.len() as u16;
12242
12243 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
12244 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
12247 push_u16(&mut out, entry_count);
12248 push_u32(&mut out, central_size);
12249 push_u32(&mut out, central_offset);
12250 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
12253 return Err(LinkError::PushTooLarge {
12254 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
12255 });
12256 }
12257 Ok(out)
12258}
12259
12260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12266pub enum Capability {
12267 Read,
12269 Write,
12271}
12272
12273impl Capability {
12274 pub fn as_str(self) -> &'static str {
12276 match self {
12277 Capability::Read => "read",
12278 Capability::Write => "write",
12279 }
12280 }
12281}
12282
12283pub fn grant_issue(
12289 cfg: &HubConfig,
12290 brain: &str,
12291 grantee: &str,
12292 can: Capability,
12293 scope: Option<&str>,
12294 until: Option<&str>,
12295) -> LinkResult<Value> {
12296 require_safe_ref(brain)?;
12297 let is_key_grantee = URL_SAFE_NO_PAD
12302 .decode(grantee)
12303 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
12304 .unwrap_or(false);
12305 if let Some(head) = v2_verified_head(cfg, brain)? {
12306 if is_key_grantee {
12307 let scope = scope.unwrap_or("");
12308 let preset = match can {
12309 Capability::Read => "viewer",
12310 Capability::Write => "editor",
12311 };
12312 let entropy = format!(
12313 "{}\0{}\0{}\0{}\0{}\0{}\0{}",
12314 normalized_origin(&cfg.hub)?,
12315 head.brain_id,
12316 head.control_revision,
12317 grantee,
12318 preset,
12319 scope,
12320 until.unwrap_or("")
12321 );
12322 let mut body = json!({
12323 "context": "external",
12324 "expected_control_revision": head.control_revision,
12325 "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
12326 "preset": preset,
12327 "principal_kind": "key",
12328 "public_key": grantee,
12329 "scope": scope,
12330 "scope_kind": "prefix",
12331 });
12332 if let Some(value) = until {
12333 body["expires_at"] = json!(value);
12334 }
12335 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
12336 let response = ensure_ok(
12337 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12338 "v2 grant issue",
12339 )?;
12340 let expected_fingerprint = identity_fingerprint(grantee)?;
12341 if response.get("v").and_then(Value::as_u64) != Some(2)
12342 || response
12343 .get("id")
12344 .and_then(Value::as_str)
12345 .is_none_or(|id| !crate::ulid::is_ulid(id))
12346 || response.get("principal_kind").and_then(Value::as_str) != Some("key")
12347 || response.get("principal_id").and_then(Value::as_str)
12348 != Some(expected_fingerprint.as_str())
12349 || response
12350 .get("control_revision")
12351 .and_then(Value::as_str)
12352 .is_none_or(|value| !is_sha256(value))
12353 {
12354 return Err(invalid_feed(
12355 "v2 grant issue response is not authority-bound",
12356 ));
12357 }
12358 return Ok(response);
12359 }
12360 let mut body = json!({ "email": grantee, "capability": can.as_str() });
12366 if let Some(value) = scope {
12367 body["scopePrefix"] = json!(value);
12368 }
12369 if let Some(value) = until {
12370 body["expiresAt"] = json!(value);
12371 }
12372 let path = format!("/api/hub/brains/{}/grants", head.brain_id);
12373 return ensure_ok(
12374 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12375 "account grant issue",
12376 );
12377 }
12378 let _ = verified_remote_head(cfg, brain, false)?;
12379 let mut body = if is_key_grantee {
12380 json!({ "keySpki": grantee, "capability": can.as_str() })
12381 } else {
12382 json!({ "email": grantee, "capability": can.as_str() })
12383 };
12384 if let Some(s) = scope {
12385 body["scopePrefix"] = json!(s);
12386 }
12387 if let Some(u) = until {
12388 body["expiresAt"] = json!(u);
12389 }
12390 let path = format!("/api/hub/brains/{brain}/grants");
12391 ensure_ok(
12392 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12393 "grant issue",
12394 )
12395}
12396
12397pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
12399 require_safe_ref(brain)?;
12400 if let Some(head) = v2_verified_head(cfg, brain)? {
12401 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
12402 let response = ensure_ok(
12403 request(cfg, "GET", &path, None, Auth::Required)?,
12404 "v2 grant list",
12405 )?;
12406 if response.get("v").and_then(Value::as_u64) != Some(2)
12407 || response.get("control_revision").and_then(Value::as_str)
12408 != Some(head.control_revision.as_str())
12409 || !response.get("grants").is_some_and(Value::is_array)
12410 {
12411 return Err(invalid_feed(
12412 "v2 grant list is not bound to the verified authority",
12413 ));
12414 }
12415 return Ok(response);
12416 }
12417 let _ = verified_remote_head(cfg, brain, false)?;
12418 let path = format!("/api/hub/brains/{brain}/grants");
12419 ensure_ok(
12420 request(cfg, "GET", &path, None, Auth::Required)?,
12421 "grant list",
12422 )
12423}
12424
12425pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
12428 require_safe_ref(brain)?;
12429 require_safe_grant_id(grant_id)?;
12430 if let Some(head) = v2_verified_head(cfg, brain)? {
12431 let entropy = format!(
12432 "{}\0{}\0{}\0{}",
12433 normalized_origin(&cfg.hub)?,
12434 head.brain_id,
12435 head.control_revision,
12436 grant_id
12437 );
12438 let body = json!({
12439 "expected_control_revision": head.control_revision,
12440 "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
12441 });
12442 let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
12443 let response = ensure_ok(
12444 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
12445 "v2 grant revoke",
12446 )?;
12447 if response.get("v").and_then(Value::as_u64) != Some(2)
12448 || response.get("id").and_then(Value::as_str) != Some(grant_id)
12449 || response.get("revoked").and_then(Value::as_bool) != Some(true)
12450 || response
12451 .get("control_revision")
12452 .and_then(Value::as_str)
12453 .is_none_or(|value| !is_sha256(value))
12454 {
12455 return Err(invalid_feed(
12456 "v2 grant revocation response is not authority-bound",
12457 ));
12458 }
12459 return Ok(response);
12460 }
12461 let _ = verified_remote_head(cfg, brain, false)?;
12462 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
12463 ensure_ok(
12464 request(cfg, "DELETE", &path, None, Auth::Required)?,
12465 "grant revoke",
12466 )
12467}
12468
12469#[derive(Debug)]
12474struct VerifiedV2Proposal {
12475 value: Value,
12476 changes: Value,
12477 blobs: Vec<(String, u64, String)>,
12478}
12479
12480fn require_proposal_id(id: &str) -> LinkResult<()> {
12481 if crate::ulid::is_ulid(id) {
12482 Ok(())
12483 } else {
12484 Err(invalid_feed("proposal id is not a lowercase ULID"))
12485 }
12486}
12487
12488fn verified_v2_proposal(
12489 cfg: &HubConfig,
12490 head: &V2VerifiedHead,
12491 proposal_id: &str,
12492) -> LinkResult<VerifiedV2Proposal> {
12493 require_proposal_id(proposal_id)?;
12494 if head.view_kind != "full" {
12495 return Err(invalid_feed(
12496 "proposal review requires a full readable view",
12497 ));
12498 }
12499 let path = format!(
12500 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
12501 head.brain_id
12502 );
12503 let value = ensure_ok(
12504 request_capped(
12505 cfg,
12506 "GET",
12507 &path,
12508 None,
12509 Auth::Required,
12510 MAX_FEED_RESPONSE_BYTES,
12511 )?,
12512 "v2 proposal",
12513 )?;
12514 verify_v2_proposal_value(head, proposal_id, value)
12515}
12516
12517fn verify_v2_proposal_value(
12518 head: &V2VerifiedHead,
12519 proposal_id: &str,
12520 value: Value,
12521) -> LinkResult<VerifiedV2Proposal> {
12522 if value.get("v").and_then(Value::as_u64) != Some(2) {
12523 return Err(invalid_feed("proposal response has an invalid version"));
12524 }
12525 let proposal = value
12526 .get("proposal")
12527 .and_then(Value::as_object)
12528 .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
12529 if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
12530 return Err(invalid_feed("proposal response changed its id"));
12531 }
12532 let payload_hash = proposal
12533 .get("payload_sha256")
12534 .and_then(Value::as_str)
12535 .filter(|hash| is_sha256(hash))
12536 .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
12537 let clear_hash = proposal
12538 .get("clear_sha256")
12539 .and_then(Value::as_str)
12540 .filter(|hash| is_sha256(hash))
12541 .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
12542 let submission_hash = proposal
12543 .get("submission_claim_sha256")
12544 .and_then(Value::as_str)
12545 .filter(|hash| is_sha256(hash))
12546 .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
12547 let submission = STANDARD
12548 .decode(
12549 proposal
12550 .get("submission_claim_base64")
12551 .and_then(Value::as_str)
12552 .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
12553 )
12554 .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
12555 let submission_value: Value = serde_json::from_slice(&submission)
12556 .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
12557 if crate::linkmd_v2::canonical_bytes(&submission_value)
12558 .map_err(|error| invalid_feed(error.to_string()))?
12559 != submission
12560 || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
12561 .map_err(|error| invalid_feed(error.to_string()))?
12562 != submission_hash
12563 {
12564 return Err(invalid_feed(
12565 "proposal submission claim is not canonical or addressed",
12566 ));
12567 }
12568 let envelope = submission_value
12569 .as_object()
12570 .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
12571 let claim = envelope
12572 .get("claim")
12573 .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
12574 let claim_object = claim
12575 .as_object()
12576 .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
12577 let actor_root = claim_object
12578 .get("actor_root")
12579 .and_then(Value::as_object)
12580 .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
12581 let public_key = envelope
12582 .get("public_key")
12583 .and_then(Value::as_str)
12584 .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
12585 let fingerprint = envelope
12586 .get("fingerprint")
12587 .and_then(Value::as_str)
12588 .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
12589 let signature = envelope
12590 .get("sig")
12591 .and_then(Value::as_str)
12592 .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
12593 let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
12594 .map_err(|error| invalid_feed(error.to_string()))?;
12595 let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
12596 let signer = format!("{fingerprint}:{public_key}");
12597 let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
12598 let grants = actor_root.get("grants").and_then(Value::as_array);
12599 let grants_are_canonical = grants.is_some_and(|items| {
12600 let mut prior: Option<&str> = None;
12601 items.iter().all(|item| {
12602 let Some(grant) = item.as_str() else {
12603 return false;
12604 };
12605 if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
12606 return false;
12607 }
12608 prior = Some(grant);
12609 true
12610 })
12611 });
12612 let optional_actor_field = |name: &str| {
12613 actor_root.get(name).is_some_and(|value| {
12614 value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
12615 })
12616 };
12617 let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
12618 if claim_object.get("v").and_then(Value::as_u64) != Some(2)
12619 || format!("{:x}", Sha256::digest(&der)) != fingerprint
12620 || head
12621 .trust
12622 .hub_signer
12623 .as_ref()
12624 .is_some_and(|known| known != &signer)
12625 || !matches!(
12626 actor_class,
12627 Some(
12628 "user"
12629 | "owned_agent"
12630 | "foreign_key"
12631 | "curation"
12632 | "inbox"
12633 | "restore"
12634 | "migration"
12635 | "operator_recovery"
12636 )
12637 )
12638 || actor_root
12639 .get("principal")
12640 .and_then(Value::as_str)
12641 .is_none_or(|value| value.is_empty())
12642 || actor_root
12643 .get("credential")
12644 .and_then(Value::as_str)
12645 .is_none_or(|value| value.is_empty())
12646 || !optional_actor_field("organization")
12647 || !optional_actor_field("role")
12648 || !grants_are_canonical
12649 || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
12650 || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12651 || !claim_object
12652 .get("mutation_id")
12653 .and_then(Value::as_str)
12654 .is_some_and(|value| {
12655 !value.is_empty()
12656 && value.len() <= 128
12657 && value.chars().enumerate().all(|(index, char)| {
12658 char.is_ascii_alphanumeric()
12659 || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
12660 })
12661 })
12662 || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
12663 || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
12664 || !claim_object
12665 .get("control_revision")
12666 .and_then(Value::as_str)
12667 .is_some_and(is_sha256)
12668 || submitted_at.is_none_or(|value| {
12669 chrono::DateTime::parse_from_rfc3339(value).is_err()
12670 || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
12671 })
12672 || !proposal
12673 .get("state")
12674 .and_then(Value::as_str)
12675 .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
12676 || proposal
12677 .get("expires_at")
12678 .and_then(Value::as_str)
12679 .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
12680 || proposal
12681 .get("proposer")
12682 .and_then(Value::as_object)
12683 .and_then(|value| value.get("class"))
12684 .and_then(Value::as_str)
12685 != actor_class
12686 {
12687 return Err(invalid_feed(
12688 "proposal submission claim does not bind the verified proposal",
12689 ));
12690 }
12691 let changes_b64 = proposal
12692 .get("changes_base64")
12693 .and_then(Value::as_str)
12694 .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
12695 let changes_bytes = STANDARD
12696 .decode(changes_b64)
12697 .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
12698 let changes: Value = serde_json::from_slice(&changes_bytes)
12699 .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
12700 if crate::linkmd_v2::canonical_bytes(&changes)
12701 .map_err(|error| invalid_feed(error.to_string()))?
12702 != changes_bytes
12703 || changes.get("v").and_then(Value::as_u64) != Some(2)
12704 || !changes.get("operations").is_some_and(Value::is_array)
12705 {
12706 return Err(invalid_feed("proposal changeset is not canonical v2"));
12707 }
12708 let blob_values = proposal
12709 .get("blobs")
12710 .and_then(Value::as_array)
12711 .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
12712 let mut blobs = Vec::with_capacity(blob_values.len());
12713 let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
12714 let mut prior_hash: Option<String> = None;
12715 for item in blob_values {
12716 let hash = item
12717 .get("sha256")
12718 .and_then(Value::as_str)
12719 .filter(|hash| is_sha256(hash))
12720 .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
12721 let bytes = item
12722 .get("bytes")
12723 .and_then(Value::as_u64)
12724 .filter(|bytes| *bytes <= MAX_STORE_BYTES)
12725 .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
12726 if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
12727 return Err(invalid_feed(
12728 "proposal blob declarations are not unique and sorted",
12729 ));
12730 }
12731 prior_hash = Some(hash.to_string());
12732 let endpoint = item
12733 .get("endpoint")
12734 .and_then(Value::as_str)
12735 .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
12736 let expected_endpoint = format!(
12737 "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
12738 head.brain_id
12739 );
12740 if endpoint != expected_endpoint {
12741 return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
12742 }
12743 descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
12744 blobs.push((hash.to_string(), bytes, endpoint.to_string()));
12745 }
12746 let descriptor = json!({
12747 "base": proposal.get("base").cloned().unwrap_or(Value::Null),
12748 "blobs": descriptor_blobs,
12749 "changes_base64": changes_b64,
12750 "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
12751 "v": 2,
12752 });
12753 let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
12754 .map_err(|error| invalid_feed(error.to_string()))?;
12755 if content_sha256(&descriptor_bytes) != clear_hash {
12756 return Err(invalid_feed(
12757 "proposal clear payload differs from its signed submission claim",
12758 ));
12759 }
12760 Ok(VerifiedV2Proposal {
12761 value,
12762 changes,
12763 blobs,
12764 })
12765}
12766
12767pub fn proposal_list(
12768 cfg: &HubConfig,
12769 brain: &str,
12770 state: &str,
12771 after: Option<&str>,
12772 limit: usize,
12773) -> LinkResult<Value> {
12774 require_safe_ref(brain)?;
12775 if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
12776 return Err(invalid_feed("proposal state is invalid"));
12777 }
12778 if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
12779 return Err(invalid_feed("proposal cursor is invalid"));
12780 }
12781 let head = v2_verified_head(cfg, brain)?
12782 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12783 let path = format!(
12784 "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
12785 head.brain_id,
12786 limit.clamp(1, 100),
12787 after.map_or_else(String::new, |value| format!("&after={value}"))
12788 );
12789 ensure_ok(
12790 request_capped(
12791 cfg,
12792 "GET",
12793 &path,
12794 None,
12795 Auth::Required,
12796 MAX_FEED_RESPONSE_BYTES,
12797 )?,
12798 "v2 proposal list",
12799 )
12800}
12801
12802pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
12803 require_safe_ref(brain)?;
12804 let head = v2_verified_head(cfg, brain)?
12805 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12806 Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
12807}
12808
12809pub fn proposal_reject(
12810 cfg: &HubConfig,
12811 brain: &str,
12812 proposal_id: &str,
12813 mutation_id: &str,
12814 reason: &str,
12815) -> LinkResult<Value> {
12816 require_safe_ref(brain)?;
12817 require_proposal_id(proposal_id)?;
12818 let head = v2_verified_head(cfg, brain)?
12819 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12820 let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
12821 let body = json!({
12822 "mutation_id": mutation_id,
12823 "control_revision": head.control_revision,
12824 "reason": reason,
12825 });
12826 let path = format!(
12827 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
12828 head.brain_id
12829 );
12830 ensure_ok(
12831 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
12832 "v2 proposal rejection",
12833 )
12834}
12835
12836pub fn proposal_accept_exact(
12837 cfg: &HubConfig,
12838 brain: &str,
12839 proposal_id: &str,
12840 mutation_id: &str,
12841 reason: &str,
12842) -> LinkResult<Value> {
12843 require_safe_ref(brain)?;
12844 require_proposal_id(proposal_id)?;
12845 let head = v2_verified_head(cfg, brain)?
12846 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12847 let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
12848 let operations = proposal
12849 .changes
12850 .get("operations")
12851 .and_then(Value::as_array)
12852 .cloned()
12853 .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
12854 if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
12855 return Err(invalid_feed("proposal operation count is invalid"));
12856 }
12857 let mut downloaded = std::collections::BTreeMap::new();
12858 for (hash, bytes, endpoint) in &proposal.blobs {
12859 let body = ensure_raw_ok(
12860 request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
12861 "v2 proposal blob",
12862 )?;
12863 if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
12864 return Err(invalid_feed("proposal blob does not match its declaration"));
12865 }
12866 downloaded.insert(hash.clone(), body);
12867 }
12868 let remote = files_for_v2_view(
12869 &head,
12870 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
12871 );
12872 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
12873 let mut expected_candidate = remote.clone();
12874 let mut expected_candidate_assets = remote_assets;
12875 for operation in &operations {
12876 let op = operation
12877 .get("op")
12878 .and_then(Value::as_str)
12879 .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
12880 match op {
12881 "put" | "put_asset_content" | "restore" => {
12882 let path = operation
12883 .get("path")
12884 .and_then(Value::as_str)
12885 .ok_or_else(|| invalid_feed("proposal write has no path"))?;
12886 crate::linkmd_v2::normalize_path(path)
12887 .map_err(|error| invalid_feed(error.to_string()))?;
12888 let hash = operation
12889 .get("blob")
12890 .and_then(Value::as_str)
12891 .filter(|hash| is_sha256(hash))
12892 .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
12893 let bytes = operation
12894 .get("bytes")
12895 .and_then(Value::as_u64)
12896 .ok_or_else(|| invalid_feed("proposal write has no size"))?;
12897 expected_candidate.insert(
12898 path.to_string(),
12899 V2BaselineFile {
12900 sha256: hash.to_string(),
12901 bytes,
12902 proof: None,
12903 },
12904 );
12905 }
12906 "delete" | "withdraw_from_hosting" => {
12907 let path = operation
12908 .get("path")
12909 .and_then(Value::as_str)
12910 .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
12911 crate::linkmd_v2::normalize_path(path)
12912 .map_err(|error| invalid_feed(error.to_string()))?;
12913 expected_candidate.remove(path);
12914 }
12915 "rename" => {
12916 let from = operation
12917 .get("from")
12918 .and_then(Value::as_str)
12919 .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
12920 let to = operation
12921 .get("to")
12922 .and_then(Value::as_str)
12923 .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
12924 crate::linkmd_v2::normalize_path(from)
12925 .and_then(|_| crate::linkmd_v2::normalize_path(to))
12926 .map_err(|error| invalid_feed(error.to_string()))?;
12927 let hash = operation
12928 .get("blob")
12929 .and_then(Value::as_str)
12930 .filter(|hash| is_sha256(hash))
12931 .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
12932 let bytes = operation
12933 .get("bytes")
12934 .and_then(Value::as_u64)
12935 .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
12936 expected_candidate.remove(from);
12937 expected_candidate.insert(
12938 to.to_string(),
12939 V2BaselineFile {
12940 sha256: hash.to_string(),
12941 bytes,
12942 proof: None,
12943 },
12944 );
12945 }
12946 "asset_delete" => {
12947 let path = operation
12948 .get("path")
12949 .and_then(Value::as_str)
12950 .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
12951 expected_candidate_assets.remove(path);
12952 }
12953 "asset_withdraw" => {
12954 let path = operation
12955 .get("path")
12956 .and_then(Value::as_str)
12957 .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
12958 if !expected_candidate_assets.contains_key(path) {
12959 return Err(invalid_feed("proposal withdraws an unknown asset"));
12960 }
12961 let Some(asset) = operation.get("asset").and_then(Value::as_object) else {
12962 let prior = expected_candidate_assets
12966 .get_mut(path)
12967 .expect("presence checked above");
12968 prior.disposition = "withheld".to_string();
12969 prior.leaf_hash.clear();
12970 continue;
12971 };
12972 let blob_sha256 = asset
12973 .get("blob_sha256")
12974 .and_then(Value::as_str)
12975 .filter(|hash| is_sha256(hash))
12976 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12977 let bytes = asset
12978 .get("bytes")
12979 .and_then(Value::as_u64)
12980 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12981 let media_type = asset
12982 .get("media_type")
12983 .and_then(Value::as_str)
12984 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12985 let wrappers = asset
12986 .get("wrappers")
12987 .and_then(Value::as_array)
12988 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12989 .iter()
12990 .map(|wrapper| {
12991 wrapper
12992 .as_str()
12993 .map(str::to_string)
12994 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12995 })
12996 .collect::<LinkResult<Vec<_>>>()?;
12997 let required = asset
12998 .get("required")
12999 .and_then(Value::as_bool)
13000 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
13001 if asset.get("disposition").and_then(Value::as_str) != Some("withheld") {
13002 return Err(invalid_feed("proposal asset withdrawal is not withheld"));
13003 }
13004 expected_candidate_assets.insert(
13005 path.to_string(),
13006 V2BaselineAsset {
13007 blob_sha256: blob_sha256.to_string(),
13008 bytes,
13009 media_type: media_type.to_string(),
13010 wrappers,
13011 required,
13012 disposition: "withheld".to_string(),
13013 leaf_hash: String::new(),
13014 },
13015 );
13016 }
13017 "asset_put" | "asset_resume" => {
13018 let path = operation
13019 .get("path")
13020 .and_then(Value::as_str)
13021 .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
13022 let asset = operation
13023 .get("asset")
13024 .and_then(Value::as_object)
13025 .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
13026 let blob_sha256 = asset
13027 .get("blob_sha256")
13028 .and_then(Value::as_str)
13029 .filter(|hash| is_sha256(hash))
13030 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
13031 let bytes = asset
13032 .get("bytes")
13033 .and_then(Value::as_u64)
13034 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
13035 let media_type = asset
13036 .get("media_type")
13037 .and_then(Value::as_str)
13038 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
13039 let wrappers = asset
13040 .get("wrappers")
13041 .and_then(Value::as_array)
13042 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
13043 .iter()
13044 .map(|wrapper| {
13045 wrapper
13046 .as_str()
13047 .map(str::to_string)
13048 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
13049 })
13050 .collect::<LinkResult<Vec<_>>>()?;
13051 let required = asset
13052 .get("required")
13053 .and_then(Value::as_bool)
13054 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
13055 let disposition = asset
13056 .get("disposition")
13057 .and_then(Value::as_str)
13058 .filter(|value| matches!(*value, "hosted" | "withheld"))
13059 .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
13060 expected_candidate_assets.insert(
13061 path.to_string(),
13062 V2BaselineAsset {
13063 blob_sha256: blob_sha256.to_string(),
13064 bytes,
13065 media_type: media_type.to_string(),
13066 wrappers,
13067 required,
13068 disposition: disposition.to_string(),
13069 leaf_hash: String::new(),
13070 },
13071 );
13072 }
13073 _ => return Err(invalid_feed("proposal operation kind is unsupported")),
13074 }
13075 }
13076 let base = head.pointer.as_ref().map(|pointer| {
13077 json!({
13078 "seq": pointer.seq,
13079 "commit_hash": pointer.commit_hash,
13080 "content_root": pointer.content_root,
13081 "asset_root": pointer.asset_root,
13082 })
13083 });
13084 let mut body = json!({
13085 "mutation_id": mutation_id,
13086 "base": base,
13087 "rebase": "strict",
13088 "reason": reason,
13089 "operations": operations,
13090 "blobs": downloaded
13091 .iter()
13092 .map(|(sha256, bytes)| json!({
13093 "sha256": sha256,
13094 "bytes": bytes.len(),
13095 "content_base64": STANDARD.encode(bytes),
13096 }))
13097 .collect::<Vec<_>>(),
13098 "proposal_id": proposal_id,
13099 "proposal_mode": "exact",
13100 });
13101 let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
13102 total
13103 .checked_add(bytes.len())
13104 .ok_or_else(|| LinkError::PushTooLarge {
13105 detail: "proposal changed-byte total overflow".to_string(),
13106 })
13107 })?;
13108 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
13109 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
13110 for operation in &operations {
13111 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
13112 return Err(invalid_feed("proposal upload operation has no kind"));
13113 };
13114 let hash = match kind {
13115 "put" | "put_asset_content" | "restore" | "rename" => {
13116 operation.get("blob").and_then(Value::as_str)
13117 }
13118 "asset_put" | "asset_resume" => operation
13119 .get("asset")
13120 .and_then(|asset| asset.get("blob_sha256"))
13121 .and_then(Value::as_str),
13122 _ => None,
13123 };
13124 let Some(hash) = hash else { continue };
13125 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
13126 if kind == "rename" {
13127 for field in ["from", "to"] {
13128 coordinates.insert(
13129 operation
13130 .get(field)
13131 .and_then(Value::as_str)
13132 .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
13133 .to_string(),
13134 );
13135 }
13136 } else {
13137 let path = operation
13138 .get("path")
13139 .and_then(Value::as_str)
13140 .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
13141 coordinates.insert(if kind.starts_with("asset_") {
13142 format!("assets/{path}")
13143 } else {
13144 path.to_string()
13145 });
13146 }
13147 }
13148 let declarations = downloaded
13149 .iter()
13150 .map(|(sha256, bytes)| {
13151 json!({
13152 "sha256": sha256,
13153 "bytes": bytes.len(),
13154 "coordinates": coordinates_by_hash
13155 .get(sha256)
13156 .into_iter()
13157 .flatten()
13158 .collect::<Vec<_>>(),
13159 })
13160 })
13161 .collect::<Vec<_>>();
13162 let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
13163 for batch in batch_upload_declarations(declarations) {
13164 let reserved = reserve_upload_window(
13165 cfg,
13166 &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
13167 &json!({ "blobs": batch }),
13168 "prepare proposal blob transport",
13169 )?;
13170 let reserved_items = reserved
13171 .get("uploads")
13172 .and_then(Value::as_array)
13173 .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
13174 items.extend(reserved_items.iter().cloned());
13175 }
13176 if items.len() != downloaded.len() {
13177 return Err(invalid_feed("proposal upload reservation changed the set"));
13178 }
13179 let mut references = Vec::with_capacity(items.len());
13180 for item in items {
13181 let hash = item
13182 .get("sha256")
13183 .and_then(Value::as_str)
13184 .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
13185 let bytes = downloaded
13186 .get(hash)
13187 .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
13188 let reservation_id = item
13189 .get("reservation_id")
13190 .and_then(Value::as_str)
13191 .filter(|id| crate::ulid::is_ulid(id))
13192 .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
13193 let expected_coordinates = coordinates_by_hash
13194 .get(hash)
13195 .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
13196 let returned_coordinates = item
13197 .get("coordinates")
13198 .and_then(Value::as_array)
13199 .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
13200 if returned_coordinates.len() != expected_coordinates.len()
13201 || returned_coordinates
13202 .iter()
13203 .zip(expected_coordinates)
13204 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
13205 {
13206 return Err(invalid_feed(
13207 "proposal upload reservation changed its coordinates",
13208 ));
13209 }
13210 match item.get("status").and_then(Value::as_str) {
13211 Some("upload") => put_presigned(
13212 cfg,
13213 item.get("url")
13214 .and_then(Value::as_str)
13215 .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
13216 item.get("headers").unwrap_or(&Value::Null),
13217 bytes,
13218 )?,
13219 Some("already_present") => {}
13220 _ => return Err(invalid_feed("proposal upload status is invalid")),
13221 }
13222 references.push(json!({
13223 "sha256": hash,
13224 "bytes": bytes.len(),
13225 "reservation_id": reservation_id,
13226 }));
13227 }
13228 body["blobs"] = Value::Array(references);
13229 }
13230 stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
13234 let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
13235 let mut result = ensure_ok(
13236 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
13237 "exact proposal acceptance",
13238 )?;
13239 let mut candidate_hub_signer = None;
13240 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
13241 let request_id = result
13242 .get("request_id")
13243 .and_then(Value::as_str)
13244 .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
13245 .to_string();
13246 let challenge = result
13247 .get("signing_challenge")
13248 .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
13249 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
13250 cfg,
13251 &head,
13252 &expected_candidate,
13253 &expected_candidate_assets,
13254 mutation_id,
13255 &v2_signed_request_view(&body, &operations),
13256 challenge,
13257 )?;
13258 body["signing_challenge_id"] = Value::String(challenge_id);
13259 body["signature_base64url"] = Value::String(signature);
13260 candidate_hub_signer = Some(actor_signer);
13261 result = ensure_ok(
13262 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
13263 "signed exact proposal acceptance",
13264 )?;
13265 }
13266 let refreshed = v2_verified_head(cfg, brain)?
13267 .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
13268 if candidate_hub_signer
13269 .as_ref()
13270 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
13271 || refreshed
13272 .pointer
13273 .as_ref()
13274 .map(|pointer| pointer.commit_hash.as_str())
13275 != result.get("commit_hash").and_then(Value::as_str)
13276 || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
13277 || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
13278 {
13279 return Err(LinkError::RemoteAdvancedDuringSync);
13280 }
13281 accept_v2_head(cfg, &refreshed)?;
13282 Ok(result)
13283}
13284
13285pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
13296 require_valid_handle(handle)?;
13297 if body.len() as u64 > MAX_PROPOSE_BYTES {
13298 return Err(LinkError::ProposeTooLarge {
13299 bytes: body.len() as u64,
13300 });
13301 }
13302 let payload = json!({ "app": app, "body": body });
13303 let (path, auth) = if crate::ulid::is_ulid(handle) {
13308 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
13309 } else {
13310 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
13311 };
13312 ensure_ok(
13313 request(cfg, "POST", &path, Some(&payload), auth)?,
13314 "propose",
13315 )
13316}
13317
13318#[derive(Debug, serde::Serialize)]
13324pub struct Head {
13325 pub brain: String,
13327 pub seq: u64,
13329 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
13331 pub updated_at: Option<String>,
13332 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
13334 pub feed_hash: Option<String>,
13335 pub verified: bool,
13338}
13339
13340struct BoundedVecVisitor<T, const MAX: usize> {
13341 label: &'static str,
13342 marker: std::marker::PhantomData<T>,
13343}
13344
13345impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
13346where
13347 T: Deserialize<'de>,
13348{
13349 type Value = Vec<T>;
13350
13351 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13352 write!(formatter, "at most {MAX} {}", self.label)
13353 }
13354
13355 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
13356 where
13357 A: serde::de::SeqAccess<'de>,
13358 {
13359 if sequence.size_hint().is_some_and(|size| size > MAX) {
13360 return Err(serde::de::Error::custom(format!(
13361 "{} exceeds the {MAX}-item limit",
13362 self.label
13363 )));
13364 }
13365 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
13366 while let Some(value) = sequence.next_element()? {
13367 if values.len() == MAX {
13368 return Err(serde::de::Error::custom(format!(
13369 "{} exceeds the {MAX}-item limit",
13370 self.label
13371 )));
13372 }
13373 values.push(value);
13374 }
13375 Ok(values)
13376 }
13377}
13378
13379fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
13380 deserializer: D,
13381 label: &'static str,
13382) -> Result<Vec<T>, D::Error>
13383where
13384 D: serde::Deserializer<'de>,
13385 T: Deserialize<'de>,
13386{
13387 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
13388 label,
13389 marker: std::marker::PhantomData,
13390 })
13391}
13392
13393fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
13394where
13395 D: serde::Deserializer<'de>,
13396{
13397 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
13398}
13399
13400fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
13401where
13402 D: serde::Deserializer<'de>,
13403{
13404 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
13405}
13406
13407fn deserialize_previous_identities<'de, D>(
13408 deserializer: D,
13409) -> Result<Vec<PreviousIdentity>, D::Error>
13410where
13411 D: serde::Deserializer<'de>,
13412{
13413 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
13414 deserializer,
13415 "previous identities",
13416 )
13417}
13418
13419fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
13420where
13421 D: serde::Deserializer<'de>,
13422{
13423 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
13424 deserializer,
13425 "rotation statements",
13426 )
13427}
13428
13429fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
13430where
13431 D: serde::Deserializer<'de>,
13432{
13433 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
13434}
13435
13436#[derive(Debug, Clone, Deserialize, Serialize)]
13437struct FeedFile {
13438 path: String,
13439 sha256: String,
13440 bytes: u64,
13441}
13442
13443#[cfg(test)]
13444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13445enum V1DisclosureError {
13446 DuplicateFile,
13447 DuplicateRemoved,
13448 PushManifestMismatch,
13449 EditMissingChange,
13450 EditFalseFile,
13451 RemovedMismatch,
13452}
13453
13454#[cfg(test)]
13458fn verify_v1_manifest_disclosure(
13459 kind: &str,
13460 previous: &[FeedFile],
13461 resulting: &[FeedFile],
13462 files: &[FeedFile],
13463 removed: &[String],
13464) -> Result<(), V1DisclosureError> {
13465 fn as_map(
13466 files: &[FeedFile],
13467 ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
13468 let mut result = std::collections::BTreeMap::new();
13469 for file in files {
13470 if result
13471 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
13472 .is_some()
13473 {
13474 return Err(V1DisclosureError::DuplicateFile);
13475 }
13476 }
13477 Ok(result)
13478 }
13479 let previous = as_map(previous)?;
13480 let resulting = as_map(resulting)?;
13481 let disclosed = as_map(files)?;
13482 let removed_set: std::collections::BTreeSet<&str> =
13483 removed.iter().map(String::as_str).collect();
13484 if removed_set.len() != removed.len() {
13485 return Err(V1DisclosureError::DuplicateRemoved);
13486 }
13487 let expected_removed: std::collections::BTreeSet<&str> = previous
13488 .keys()
13489 .copied()
13490 .filter(|path| !resulting.contains_key(path))
13491 .collect();
13492 if removed_set != expected_removed {
13493 return Err(V1DisclosureError::RemovedMismatch);
13494 }
13495 if kind == "push" {
13496 return if disclosed == resulting {
13497 Ok(())
13498 } else {
13499 Err(V1DisclosureError::PushManifestMismatch)
13500 };
13501 }
13502 if kind != "edit" {
13503 return Err(V1DisclosureError::EditFalseFile);
13504 }
13505 if disclosed
13506 .iter()
13507 .any(|(path, value)| resulting.get(path) != Some(value))
13508 {
13509 return Err(V1DisclosureError::EditFalseFile);
13510 }
13511 for (path, value) in &resulting {
13512 if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
13513 return Err(V1DisclosureError::EditMissingChange);
13514 }
13515 }
13516 Ok(())
13517}
13518
13519#[derive(Debug, Clone, Deserialize, Serialize)]
13520struct FeedEntry {
13521 v: u8,
13522 seq: u64,
13523 ts: String,
13524 brain: String,
13525 public_key: String,
13526 kind: String,
13527 op: String,
13528 pack_sha256: String,
13529 #[serde(deserialize_with = "deserialize_feed_files")]
13530 files: Vec<FeedFile>,
13531 #[serde(deserialize_with = "deserialize_removed_paths")]
13532 removed: Vec<String>,
13533 prev_entry_hash: Option<String>,
13534 sig: String,
13535}
13536
13537#[derive(Serialize)]
13538struct UnsignedFeedEntry<'a> {
13539 v: u8,
13540 seq: u64,
13541 ts: &'a str,
13542 brain: &'a str,
13543 public_key: &'a str,
13544 kind: &'a str,
13545 op: &'a str,
13546 pack_sha256: &'a str,
13547 files: &'a [FeedFile],
13548 removed: &'a [String],
13549 prev_entry_hash: &'a Option<String>,
13550}
13551
13552#[derive(Debug, Clone, Deserialize, Serialize)]
13553struct FeedItem {
13554 hash: String,
13555 entry: FeedEntry,
13556}
13557
13558#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
13559struct FeedIdentity {
13560 fingerprint: String,
13561 #[serde(rename = "publicKeySpki")]
13562 public_key_spki: String,
13563 #[serde(default, deserialize_with = "deserialize_previous_identities")]
13567 previous: Vec<PreviousIdentity>,
13568 #[serde(default, deserialize_with = "deserialize_rotations")]
13571 rotations: Vec<String>,
13572}
13573
13574#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
13575struct PreviousIdentity {
13576 fingerprint: String,
13577 #[serde(rename = "publicKeySpki")]
13578 public_key_spki: String,
13579}
13580
13581#[derive(Debug, Deserialize)]
13582struct FeedResponse {
13583 #[serde(rename = "headSeq")]
13584 head_seq: u64,
13585 #[serde(rename = "feedHash")]
13586 feed_hash: Option<String>,
13587 identity: Option<FeedIdentity>,
13588 #[serde(deserialize_with = "deserialize_feed_items")]
13589 entries: Vec<FeedItem>,
13590 #[serde(rename = "scopeLimited")]
13591 scope_limited: bool,
13592}
13593
13594#[derive(Debug, Deserialize, Serialize)]
13595#[serde(deny_unknown_fields)]
13596struct RotationStatement {
13597 v: u8,
13598 op: String,
13599 brain: String,
13600 public_key: String,
13601 new_brain: String,
13602 new_public_key: String,
13603 prior_head_seq: u64,
13604 prior_feed_hash: Option<String>,
13605 ts: String,
13606 sig: String,
13607}
13608
13609#[derive(Debug, Clone, Deserialize, Serialize)]
13610struct TrustState {
13611 v: u8,
13612 origin: String,
13613 #[serde(default)]
13617 requested: String,
13618 brain: String,
13620 #[serde(default, skip_serializing_if = "Option::is_none")]
13623 home: Option<String>,
13624 anchor: String,
13625 current: String,
13626 #[serde(rename = "headSeq")]
13627 head_seq: u64,
13628 #[serde(rename = "feedHash")]
13629 feed_hash: Option<String>,
13630 #[serde(default)]
13634 rotations: Vec<String>,
13635 #[serde(default, skip_serializing_if = "Option::is_none")]
13638 hub_signer: Option<String>,
13639 #[serde(default, skip_serializing_if = "Option::is_none")]
13642 protocol_profile: Option<String>,
13643}
13644
13645fn accepted_as_v2(state: &TrustState) -> bool {
13646 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
13647}
13648
13649fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
13650 let directory = open_trust_dir(cfg)?;
13651 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
13652 return Ok(true);
13653 }
13654 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
13655 return Ok(false);
13656 };
13657 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
13658}
13659
13660#[derive(Debug, Clone, Deserialize, Serialize)]
13661struct AliasBinding {
13662 v: u8,
13663 origin: String,
13664 requested: String,
13665 brain: String,
13666 #[serde(default, skip_serializing_if = "Option::is_none")]
13667 home: Option<String>,
13668}
13669
13670struct VerifiedRemote {
13671 head: Head,
13672 identity: Option<FeedIdentity>,
13673 head_entry: Option<FeedItem>,
13674 entries: Vec<FeedItem>,
13676 anchor: Option<String>,
13677}
13678
13679fn invalid_feed(message: impl Into<String>) -> LinkError {
13680 LinkError::InvalidFeed {
13681 message: message.into(),
13682 }
13683}
13684
13685fn is_sha256(value: &str) -> bool {
13686 value.len() == 64
13687 && value
13688 .bytes()
13689 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
13690}
13691
13692fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
13693 let der = URL_SAFE_NO_PAD
13694 .decode(public_key_spki)
13695 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
13696 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
13697 return Err(invalid_feed(
13698 "identity public key is not a valid Ed25519 SPKI",
13699 ));
13700 }
13701 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
13702}
13703
13704fn verify_identity_chain(
13708 identity: &FeedIdentity,
13709 pinned: Option<&TrustState>,
13710) -> LinkResult<String> {
13711 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
13712 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
13713 {
13714 return Err(invalid_feed(
13715 "identity rotation history exceeds the client cap",
13716 ));
13717 }
13718 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
13719 return Err(invalid_feed(
13720 "current identity fingerprint does not match its public key",
13721 ));
13722 }
13723 for previous in &identity.previous {
13724 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
13725 return Err(invalid_feed(
13726 "previous identity fingerprint does not match its public key",
13727 ));
13728 }
13729 }
13730 if identity.rotations.len() != identity.previous.len() {
13731 return Err(invalid_feed(
13732 "identity history is missing an old-key-signed rotation statement",
13733 ));
13734 }
13735
13736 let mut chain: Vec<(&str, &str)> = identity
13740 .previous
13741 .iter()
13742 .rev()
13743 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
13744 .collect();
13745 chain.push((&identity.fingerprint, &identity.public_key_spki));
13746
13747 for (index, raw) in identity.rotations.iter().enumerate() {
13748 let statement: RotationStatement = serde_json::from_str(raw)
13749 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
13750 let (old_fingerprint, old_spki) = chain[index];
13751 let (new_fingerprint, new_spki) = chain[index + 1];
13752 if statement.v != 1
13753 || statement.op != "rotate"
13754 || statement.brain != format!("ed25519:{old_fingerprint}")
13755 || statement.public_key != old_spki
13756 || statement.new_brain != format!("ed25519:{new_fingerprint}")
13757 || statement.new_public_key != new_spki
13758 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
13759 || (statement.prior_head_seq > 0
13760 && statement
13761 .prior_feed_hash
13762 .as_deref()
13763 .is_none_or(|hash| !is_sha256(hash)))
13764 {
13765 return Err(invalid_feed(
13766 "rotation statement does not connect adjacent identities",
13767 ));
13768 }
13769 let unsigned = serde_json::to_string(&UnsignedRotation {
13770 v: statement.v,
13771 op: &statement.op,
13772 brain: &statement.brain,
13773 public_key: &statement.public_key,
13774 new_brain: &statement.new_brain,
13775 new_public_key: &statement.new_public_key,
13776 prior_head_seq: statement.prior_head_seq,
13777 prior_feed_hash: statement.prior_feed_hash.as_deref(),
13778 ts: statement.ts.clone(),
13779 })
13780 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
13781 let exact = format!(
13782 "{},\"sig\":\"{}\"}}",
13783 &unsigned[..unsigned.len() - 1],
13784 statement.sig
13785 );
13786 if exact != *raw {
13787 return Err(invalid_feed(
13788 "rotation statement is not in normative serialization",
13789 ));
13790 }
13791 let der = URL_SAFE_NO_PAD
13792 .decode(old_spki)
13793 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
13794 let signature = URL_SAFE_NO_PAD
13795 .decode(&statement.sig)
13796 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
13797 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
13798 .verify(unsigned.as_bytes(), &signature)
13799 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
13800 if index > 0 {
13801 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
13802 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
13803 if statement.prior_head_seq < prior.prior_head_seq {
13804 return Err(invalid_feed("rotation feed boundaries move backward"));
13805 }
13806 }
13807 }
13808
13809 let anchor = format!("ed25519:{}", chain[0].0);
13810 let current = format!("ed25519:{}", identity.fingerprint);
13811 if let Some(pin) = pinned {
13812 if pin.anchor != anchor {
13813 return Err(invalid_feed(
13814 "served identity chain does not descend from the pinned anchor",
13815 ));
13816 }
13817 if !chain
13818 .iter()
13819 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
13820 {
13821 return Err(invalid_feed(
13822 "served identity chain forked away from the last pinned identity",
13823 ));
13824 }
13825 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
13826 return Err(invalid_feed("served identity discarded its rotation chain"));
13827 }
13828 if pin.v >= 2
13829 && (identity.rotations.len() < pin.rotations.len()
13830 || identity.rotations[..pin.rotations.len()] != pin.rotations)
13831 {
13832 return Err(invalid_feed(
13833 "served identity rewrote the locally accepted rotation history",
13834 ));
13835 }
13836 }
13837 Ok(anchor)
13838}
13839
13840fn verify_rotation_feed_boundaries(
13841 identity: &FeedIdentity,
13842 pinned: Option<&TrustState>,
13843 observed: &[FeedItem],
13844 advertised_seq: u64,
13845) -> LinkResult<()> {
13846 let mut chain: Vec<String> = identity
13847 .previous
13848 .iter()
13849 .rev()
13850 .map(|previous| format!("ed25519:{}", previous.fingerprint))
13851 .collect();
13852 chain.push(format!("ed25519:{}", identity.fingerprint));
13853 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
13854
13855 for (index, raw) in identity.rotations.iter().enumerate() {
13856 let rotation: RotationStatement = serde_json::from_str(raw)
13857 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13858 if rotation.prior_head_seq > advertised_seq {
13859 return Err(invalid_feed(
13860 "rotation claims a feed boundary beyond the advertised head",
13861 ));
13862 }
13863 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
13864 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
13865 return Err(invalid_feed(
13866 "newly disclosed rotation predates the local feed checkpoint",
13867 ));
13868 }
13869 }
13870 let actual = if rotation.prior_head_seq == 0 {
13871 None
13872 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
13873 pinned.and_then(|pin| pin.feed_hash.as_deref())
13874 } else {
13875 observed
13876 .iter()
13877 .find(|item| item.entry.seq == rotation.prior_head_seq)
13878 .map(|item| item.hash.as_str())
13879 };
13880 if let Some(actual) = actual {
13881 if rotation.prior_feed_hash.as_deref() != Some(actual) {
13882 return Err(invalid_feed(
13883 "rotation statement does not commit the verified feed boundary",
13884 ));
13885 }
13886 } else if rotation.prior_head_seq == 0 {
13887 } else if pinned.is_some_and(|pin| {
13890 pinned_index.is_some_and(|pin_index| index >= pin_index)
13891 || rotation.prior_head_seq >= pin.head_seq
13892 }) {
13893 return Err(invalid_feed(
13894 "rotation feed boundary was not present in the verified chain",
13895 ));
13896 }
13897 }
13898 Ok(())
13899}
13900
13901fn reject_retired_signer_after_checkpoint(
13906 identity: &FeedIdentity,
13907 pinned: Option<&TrustState>,
13908 item: &FeedItem,
13909) -> LinkResult<()> {
13910 let Some(pin) = pinned else {
13911 return Ok(());
13912 };
13913 if item.entry.seq <= pin.head_seq {
13914 return Ok(());
13915 }
13916 let mut chain: Vec<String> = identity
13917 .previous
13918 .iter()
13919 .rev()
13920 .map(|previous| format!("ed25519:{}", previous.fingerprint))
13921 .collect();
13922 chain.push(format!("ed25519:{}", identity.fingerprint));
13923 let pinned_index = chain
13924 .iter()
13925 .position(|key| key == &pin.current)
13926 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
13927 let signer_index = chain
13928 .iter()
13929 .position(|key| key == &item.entry.brain)
13930 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
13931 if signer_index < pinned_index {
13932 return Err(invalid_feed(
13933 "a retired identity attempted to sign after the local checkpoint",
13934 ));
13935 }
13936 Ok(())
13937}
13938
13939fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
13940 let origin = normalized_origin(&cfg.hub)?;
13941 let key = format!(
13942 "{:x}",
13943 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
13944 );
13945 Ok(format!("{key}.json"))
13946}
13947
13948fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
13949 let origin = normalized_origin(&cfg.hub)?;
13950 let key = format!(
13951 "{:x}",
13952 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
13953 );
13954 Ok(format!("alias-{key}.json"))
13955}
13956
13957#[cfg(any(unix, windows))]
13958struct TrustLock {
13959 _file: std::fs::File,
13960}
13961
13962#[cfg(unix)]
13963fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13964 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13965
13966 let lock_string = format!(".{state_name}.lock");
13967 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
13968 let fd = unsafe {
13969 libc::openat(
13970 directory.as_raw_fd(),
13971 lock_name.as_ptr(),
13972 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13973 0o600,
13974 )
13975 };
13976 if fd < 0 {
13977 return Err(std::io::Error::last_os_error().into());
13978 }
13979 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13980 if !file.metadata()?.is_file() {
13981 return Err(LinkError::UnsafePath { path: lock_string });
13982 }
13983 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
13984 return Err(std::io::Error::last_os_error().into());
13985 }
13986 Ok(TrustLock { _file: file })
13987}
13988
13989#[cfg(windows)]
13990fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13991 let lock_name = format!(".{state_name}.lock");
13992 let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
13993 Ok(TrustLock { _file: file })
13994}
13995
13996#[cfg(any(unix, windows))]
13997fn lock_trust_many(
13998 cfg: &HubConfig,
13999 directory: &std::fs::File,
14000 refs: &[&str],
14001) -> LinkResult<Vec<TrustLock>> {
14002 let mut names = refs
14003 .iter()
14004 .map(|reference| trust_file_name(cfg, reference))
14005 .collect::<LinkResult<Vec<_>>>()?;
14006 names.sort();
14007 names.dedup();
14008 names
14009 .iter()
14010 .map(|name| lock_trust_name(directory, name))
14011 .collect()
14012}
14013
14014#[cfg(not(any(unix, windows)))]
14015fn lock_trust_many(
14016 _cfg: &HubConfig,
14017 _directory: &TrustDirectory,
14018 _refs: &[&str],
14019) -> LinkResult<Vec<()>> {
14020 Err(LinkError::UnsupportedPlatform {
14021 operation: "verified link.md state",
14022 })
14023}
14024
14025#[cfg(any(unix, windows))]
14026type TrustDirectory = std::fs::File;
14027
14028#[cfg(not(any(unix, windows)))]
14029struct TrustDirectory;
14030
14031#[cfg(unix)]
14032fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
14033 use std::os::fd::AsRawFd as _;
14034
14035 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
14036 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
14037 return Err(std::io::Error::last_os_error().into());
14038 }
14039 directory.sync_all()?;
14040 Ok(directory)
14041}
14042
14043#[cfg(windows)]
14044fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
14045 let marker = cfg.state_dir.join("trust").join(".directory");
14046 crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
14047 Ok(crate::fsx::open_directory_nofollow(
14048 marker.parent().expect("trust marker has a parent"),
14049 )?)
14050}
14051
14052#[cfg(not(any(unix, windows)))]
14053fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
14054 Err(LinkError::UnsupportedPlatform {
14055 operation: "verified link.md state",
14056 })
14057}
14058
14059#[cfg(unix)]
14060fn load_trust_in(
14061 cfg: &HubConfig,
14062 directory: &TrustDirectory,
14063 requested: &str,
14064) -> LinkResult<Option<TrustState>> {
14065 use std::os::fd::{AsRawFd as _, FromRawFd as _};
14066
14067 let name_string = trust_file_name(cfg, requested)?;
14068 let name = c_name(name_string.as_bytes(), &name_string)?;
14069 let fd = unsafe {
14070 libc::openat(
14071 directory.as_raw_fd(),
14072 name.as_ptr(),
14073 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14074 )
14075 };
14076 if fd < 0 {
14077 let error = std::io::Error::last_os_error();
14078 if error.kind() == std::io::ErrorKind::NotFound {
14079 return Ok(None);
14080 }
14081 return Err(LinkError::UnsafePath { path: name_string });
14082 }
14083 let file = unsafe { std::fs::File::from_raw_fd(fd) };
14084 if !file.metadata()?.is_file() {
14085 return Err(LinkError::UnsafePath { path: name_string });
14086 }
14087 let mut bytes = Vec::new();
14088 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
14089 if bytes.len() > 1024 * 1024 {
14090 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
14091 }
14092 let mut state: TrustState = serde_json::from_slice(&bytes)
14093 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
14094 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
14095 return Err(invalid_feed(
14096 "local identity/feed checkpoint does not match this hub and brain",
14097 ));
14098 }
14099 if state.v == 1 {
14100 if state.brain != requested {
14104 return Err(invalid_feed(
14105 "legacy checkpoint is not bound to the requested brain id",
14106 ));
14107 }
14108 state.requested = requested.to_string();
14109 } else if state.requested != requested {
14110 return Err(invalid_feed(
14111 "local identity/feed checkpoint is bound to a different requested ref",
14112 ));
14113 }
14114 Ok(Some(state))
14115}
14116
14117#[cfg(windows)]
14118fn load_trust_in(
14119 cfg: &HubConfig,
14120 directory: &TrustDirectory,
14121 requested: &str,
14122) -> LinkResult<Option<TrustState>> {
14123 let name = trust_file_name(cfg, requested)?;
14124 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
14125 let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
14126 Ok(bytes) => bytes,
14127 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
14128 Err(_) => return Err(LinkError::UnsafePath { path: name }),
14129 };
14130 let mut state: TrustState = serde_json::from_slice(&bytes)
14131 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
14132 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
14133 return Err(invalid_feed(
14134 "local identity/feed checkpoint does not match this hub and brain",
14135 ));
14136 }
14137 if state.v == 1 {
14138 if state.brain != requested {
14139 return Err(invalid_feed(
14140 "legacy checkpoint is not bound to the requested brain id",
14141 ));
14142 }
14143 state.requested = requested.to_string();
14144 } else if state.requested != requested {
14145 return Err(invalid_feed(
14146 "local identity/feed checkpoint is bound to a different requested ref",
14147 ));
14148 }
14149 Ok(Some(state))
14150}
14151
14152#[cfg(not(any(unix, windows)))]
14153fn load_trust_in(
14154 _cfg: &HubConfig,
14155 _directory: &TrustDirectory,
14156 _brain: &str,
14157) -> LinkResult<Option<TrustState>> {
14158 Err(LinkError::UnsupportedPlatform {
14159 operation: "verified link.md state",
14160 })
14161}
14162
14163#[cfg(all(test, any(unix, windows)))]
14164fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
14165 let directory = open_trust_dir(cfg)?;
14166 load_trust_in(cfg, &directory, requested)
14167}
14168
14169#[cfg(unix)]
14170fn save_trust_in(
14171 cfg: &HubConfig,
14172 directory: &TrustDirectory,
14173 state: &TrustState,
14174) -> LinkResult<()> {
14175 use std::os::fd::{AsRawFd as _, FromRawFd as _};
14176
14177 let name_string = trust_file_name(cfg, &state.requested)?;
14178 let name = c_name(name_string.as_bytes(), &name_string)?;
14179 let mut bytes = serde_json::to_vec(state)
14180 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
14181 bytes.push(b'\n');
14182
14183 let nonce = std::time::SystemTime::now()
14184 .duration_since(std::time::UNIX_EPOCH)
14185 .unwrap_or_default()
14186 .as_nanos();
14187 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
14188 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
14189 let fd = unsafe {
14190 libc::openat(
14191 directory.as_raw_fd(),
14192 temp.as_ptr(),
14193 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14194 0o600,
14195 )
14196 };
14197 if fd < 0 {
14198 return Err(std::io::Error::last_os_error().into());
14199 }
14200 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
14201 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
14202 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14203 return Err(error.into());
14204 }
14205 drop(file);
14206 if unsafe {
14207 libc::renameat(
14208 directory.as_raw_fd(),
14209 temp.as_ptr(),
14210 directory.as_raw_fd(),
14211 name.as_ptr(),
14212 )
14213 } != 0
14214 {
14215 let error = std::io::Error::last_os_error();
14216 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14217 return Err(error.into());
14218 }
14219 directory.sync_all()?;
14220 Ok(())
14221}
14222
14223#[cfg(windows)]
14224fn save_trust_in(
14225 cfg: &HubConfig,
14226 directory: &TrustDirectory,
14227 state: &TrustState,
14228) -> LinkResult<()> {
14229 let name = trust_file_name(cfg, &state.requested)?;
14230 let mut bytes = serde_json::to_vec(state)
14231 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
14232 bytes.push(b'\n');
14233 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
14234 Ok(())
14235}
14236
14237#[cfg(not(any(unix, windows)))]
14238fn save_trust_in(
14239 _cfg: &HubConfig,
14240 _directory: &TrustDirectory,
14241 _state: &TrustState,
14242) -> LinkResult<()> {
14243 Err(LinkError::UnsupportedPlatform {
14244 operation: "verified link.md state",
14245 })
14246}
14247
14248#[cfg(unix)]
14249fn load_alias_in(
14250 cfg: &HubConfig,
14251 directory: &TrustDirectory,
14252 requested: &str,
14253) -> LinkResult<Option<AliasBinding>> {
14254 use std::os::fd::{AsRawFd as _, FromRawFd as _};
14255
14256 let name_string = alias_file_name(cfg, requested)?;
14257 let name = c_name(name_string.as_bytes(), &name_string)?;
14258 let fd = unsafe {
14259 libc::openat(
14260 directory.as_raw_fd(),
14261 name.as_ptr(),
14262 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14263 )
14264 };
14265 if fd < 0 {
14266 let error = std::io::Error::last_os_error();
14267 if error.kind() == std::io::ErrorKind::NotFound {
14268 return Ok(None);
14269 }
14270 return Err(LinkError::UnsafePath { path: name_string });
14271 }
14272 let file = unsafe { std::fs::File::from_raw_fd(fd) };
14273 if !file.metadata()?.is_file() {
14274 return Err(LinkError::UnsafePath { path: name_string });
14275 }
14276 let mut bytes = Vec::new();
14277 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
14278 if bytes.len() > 64 * 1024 {
14279 return Err(invalid_feed("local alias binding is oversized"));
14280 }
14281 let alias: AliasBinding = serde_json::from_slice(&bytes)
14282 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
14283 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
14284 {
14285 return Err(invalid_feed(
14286 "local alias binding does not match this hub and requested ref",
14287 ));
14288 }
14289 Ok(Some(alias))
14290}
14291
14292#[cfg(windows)]
14293fn load_alias_in(
14294 cfg: &HubConfig,
14295 directory: &TrustDirectory,
14296 requested: &str,
14297) -> LinkResult<Option<AliasBinding>> {
14298 let name = alias_file_name(cfg, requested)?;
14299 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
14300 let bytes = match reader.read(Path::new(&name), 64 * 1024) {
14301 Ok(bytes) => bytes,
14302 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
14303 Err(_) => return Err(LinkError::UnsafePath { path: name }),
14304 };
14305 let alias: AliasBinding = serde_json::from_slice(&bytes)
14306 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
14307 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
14308 {
14309 return Err(invalid_feed(
14310 "local alias binding does not match this hub and requested ref",
14311 ));
14312 }
14313 Ok(Some(alias))
14314}
14315
14316#[cfg(not(any(unix, windows)))]
14317fn load_alias_in(
14318 _cfg: &HubConfig,
14319 _directory: &TrustDirectory,
14320 _requested: &str,
14321) -> LinkResult<Option<AliasBinding>> {
14322 Err(LinkError::UnsupportedPlatform {
14323 operation: "verified link.md state",
14324 })
14325}
14326
14327#[cfg(unix)]
14328fn save_alias_in(
14329 cfg: &HubConfig,
14330 directory: &TrustDirectory,
14331 alias: &AliasBinding,
14332) -> LinkResult<()> {
14333 use std::os::fd::{AsRawFd as _, FromRawFd as _};
14334
14335 let name_string = alias_file_name(cfg, &alias.requested)?;
14336 let name = c_name(name_string.as_bytes(), &name_string)?;
14337 let mut bytes = serde_json::to_vec(alias)
14338 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
14339 bytes.push(b'\n');
14340 let nonce = std::time::SystemTime::now()
14341 .duration_since(std::time::UNIX_EPOCH)
14342 .unwrap_or_default()
14343 .as_nanos();
14344 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
14345 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
14346 let fd = unsafe {
14347 libc::openat(
14348 directory.as_raw_fd(),
14349 temp.as_ptr(),
14350 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14351 0o600,
14352 )
14353 };
14354 if fd < 0 {
14355 return Err(std::io::Error::last_os_error().into());
14356 }
14357 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
14358 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
14359 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14360 return Err(error.into());
14361 }
14362 drop(file);
14363 if unsafe {
14364 libc::renameat(
14365 directory.as_raw_fd(),
14366 temp.as_ptr(),
14367 directory.as_raw_fd(),
14368 name.as_ptr(),
14369 )
14370 } != 0
14371 {
14372 let error = std::io::Error::last_os_error();
14373 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14374 return Err(error.into());
14375 }
14376 directory.sync_all()?;
14377 Ok(())
14378}
14379
14380#[cfg(windows)]
14381fn save_alias_in(
14382 cfg: &HubConfig,
14383 directory: &TrustDirectory,
14384 alias: &AliasBinding,
14385) -> LinkResult<()> {
14386 let name = alias_file_name(cfg, &alias.requested)?;
14387 let mut bytes = serde_json::to_vec(alias)
14388 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
14389 bytes.push(b'\n');
14390 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
14391 Ok(())
14392}
14393
14394#[cfg(not(any(unix, windows)))]
14395fn save_alias_in(
14396 _cfg: &HubConfig,
14397 _directory: &TrustDirectory,
14398 _alias: &AliasBinding,
14399) -> LinkResult<()> {
14400 Err(LinkError::UnsupportedPlatform {
14401 operation: "verified link.md state",
14402 })
14403}
14404
14405fn load_canonical_pin(
14410 cfg: &HubConfig,
14411 directory: &TrustDirectory,
14412 requested: &str,
14413 resolved_brain: &str,
14414) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
14415 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
14416 if requested == resolved_brain {
14417 return Ok((canonical, None));
14418 }
14419
14420 let mut alias = load_alias_in(cfg, directory, requested)?;
14421 if let Some(binding) = &alias {
14422 if binding.brain != resolved_brain {
14423 return Err(LinkError::AliasRebindRequired {
14424 alias: requested.to_string(),
14425 from: binding.brain.clone(),
14426 to: resolved_brain.to_string(),
14427 });
14428 }
14429 return Ok((canonical, alias));
14430 }
14431
14432 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
14436 if legacy.brain != resolved_brain {
14437 return Err(invalid_feed(
14438 "legacy alias checkpoint names a different canonical brain",
14439 ));
14440 }
14441 if let Some(existing) = &canonical {
14442 if existing.brain != legacy.brain
14443 || existing.anchor != legacy.anchor
14444 || existing.current != legacy.current
14445 || existing.head_seq != legacy.head_seq
14446 || existing.feed_hash != legacy.feed_hash
14447 || existing.rotations != legacy.rotations
14448 {
14449 return Err(invalid_feed(
14450 "legacy alias checkpoint conflicts with the canonical checkpoint",
14451 ));
14452 }
14453 } else {
14454 let mut promoted = legacy.clone();
14455 promoted.requested = resolved_brain.to_string();
14456 promoted.home = None;
14457 save_trust_in(cfg, directory, &promoted)?;
14458 canonical = Some(promoted);
14459 }
14460 alias = Some(AliasBinding {
14461 v: 1,
14462 origin: normalized_origin(&cfg.hub)?,
14463 requested: requested.to_string(),
14464 brain: resolved_brain.to_string(),
14465 home: legacy.home,
14466 });
14467 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
14468 }
14469 Ok((canonical, alias))
14470}
14471
14472pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
14477 require_hardened_filesystem("verified alias rebind")?;
14478 require_safe_ref(alias)?;
14479 require_safe_ref(from)?;
14480 require_safe_ref(to)?;
14481 if crate::ulid::is_ulid(alias)
14482 || !crate::ulid::is_ulid(from)
14483 || !crate::ulid::is_ulid(to)
14484 || from == to
14485 {
14486 return Err(LinkError::InvalidPack {
14487 message:
14488 "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
14489 .to_string(),
14490 });
14491 }
14492
14493 let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
14494 message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
14495 })?;
14496 accept_v2_head(cfg, &verified)?;
14497
14498 let alias_response = ensure_ok(
14499 request(
14500 cfg,
14501 "GET",
14502 &format!("/api/hub/brains/{alias}/v2/head"),
14503 None,
14504 Auth::Required,
14505 )?,
14506 "resolve alias for explicit rebind",
14507 )?;
14508 let resolved: V2HeadResponse = serde_json::from_value(alias_response)
14509 .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
14510 if resolved.v != 2 || resolved.brain_id != to {
14511 return Err(LinkError::RemoteAdvancedDuringSync);
14512 }
14513
14514 let directory = open_trust_dir(cfg)?;
14515 let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
14516 let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
14517 message: "the requested alias has no existing local binding to replace".to_string(),
14518 })?;
14519 if binding.brain != from {
14520 return Err(LinkError::AliasRebindRequired {
14521 alias: alias.to_string(),
14522 from: binding.brain,
14523 to: to.to_string(),
14524 });
14525 }
14526 save_alias_in(
14527 cfg,
14528 &directory,
14529 &AliasBinding {
14530 v: 1,
14531 origin: normalized_origin(&cfg.hub)?,
14532 requested: alias.to_string(),
14533 brain: to.to_string(),
14534 home: binding.home,
14535 },
14536 )?;
14537 Ok(json!({
14538 "v": 2,
14539 "alias": alias,
14540 "from": from,
14541 "to": to,
14542 "outcome": "alias_rebound",
14543 }))
14544}
14545
14546fn save_canonical_pin_and_alias(
14547 cfg: &HubConfig,
14548 directory: &TrustDirectory,
14549 requested: &str,
14550 resolved_brain: &str,
14551 mut state: TrustState,
14552 existing_alias: Option<&AliasBinding>,
14553) -> LinkResult<()> {
14554 state.requested = resolved_brain.to_string();
14555 state.brain = resolved_brain.to_string();
14556 state.home = None;
14557 save_trust_in(cfg, directory, &state)?;
14558 if requested != resolved_brain {
14559 save_alias_in(
14560 cfg,
14561 directory,
14562 &AliasBinding {
14563 v: 1,
14564 origin: normalized_origin(&cfg.hub)?,
14565 requested: requested.to_string(),
14566 brain: resolved_brain.to_string(),
14567 home: existing_alias.and_then(|alias| alias.home.clone()),
14568 },
14569 )?;
14570 }
14571 Ok(())
14572}
14573
14574fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
14575 const ED25519_SPKI_PREFIX: &[u8] = &[
14576 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
14577 ];
14578 let entry = &item.entry;
14579 let public_der = URL_SAFE_NO_PAD
14580 .decode(&entry.public_key)
14581 .map_err(|_| invalid_feed("public key is not base64url"))?;
14582 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
14583 || !public_der.starts_with(ED25519_SPKI_PREFIX)
14584 {
14585 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
14586 }
14587 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
14588 if entry.brain != format!("ed25519:{fingerprint}") {
14589 return Err(invalid_feed(
14590 "brain fingerprint does not match its public key",
14591 ));
14592 }
14593 let _ = verify_identity_chain(identity, None)?;
14595 let mut chain: Vec<(&str, &str)> = identity
14596 .previous
14597 .iter()
14598 .rev()
14599 .map(|previous| {
14600 (
14601 previous.fingerprint.as_str(),
14602 previous.public_key_spki.as_str(),
14603 )
14604 })
14605 .collect();
14606 chain.push((&identity.fingerprint, &identity.public_key_spki));
14607 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
14608 *known_fingerprint == fingerprint && *spki == entry.public_key
14609 });
14610 let Some(signer_index) = signer_index else {
14611 return Err(invalid_feed(
14612 "entry signer is not this brain's identity (current or rotated-from)",
14613 ));
14614 };
14615 let lower_boundary = if signer_index == 0 {
14616 None
14617 } else {
14618 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
14619 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
14620 Some(prior.prior_head_seq)
14621 };
14622 let upper_boundary = if signer_index == identity.rotations.len() {
14623 None
14624 } else {
14625 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
14626 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
14627 Some(next.prior_head_seq)
14628 };
14629 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
14630 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
14631 {
14632 return Err(invalid_feed(
14633 "entry signer is outside its authenticated rotation epoch",
14634 ));
14635 }
14636 let unsigned = UnsignedFeedEntry {
14637 v: entry.v,
14638 seq: entry.seq,
14639 ts: &entry.ts,
14640 brain: &entry.brain,
14641 public_key: &entry.public_key,
14642 kind: &entry.kind,
14643 op: &entry.op,
14644 pack_sha256: &entry.pack_sha256,
14645 files: &entry.files,
14646 removed: &entry.removed,
14647 prev_entry_hash: &entry.prev_entry_hash,
14648 };
14649 let message =
14650 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
14651 let signature = URL_SAFE_NO_PAD
14652 .decode(&entry.sig)
14653 .map_err(|_| invalid_feed("signature is not base64url"))?;
14654 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
14655 .verify(&message, &signature)
14656 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
14657
14658 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
14659 exact.push(b'\n');
14660 let actual_hash = format!("{:x}", Sha256::digest(&exact));
14661 if actual_hash != item.hash {
14662 return Err(invalid_feed("entry SHA-256 does not match"));
14663 }
14664 Ok(())
14665}
14666
14667#[derive(Serialize)]
14673struct UnsignedRotation<'a> {
14674 v: u8,
14675 op: &'a str,
14676 brain: &'a str,
14677 public_key: &'a str,
14678 new_brain: &'a str,
14679 new_public_key: &'a str,
14680 prior_head_seq: u64,
14681 prior_feed_hash: Option<&'a str>,
14682 ts: String,
14683}
14684
14685#[derive(Debug, Deserialize, Serialize)]
14690#[serde(deny_unknown_fields)]
14691struct RotationJournal {
14692 v: u8,
14693 origin: String,
14694 brain: String,
14695 old_brain: String,
14696 new_brain: String,
14697 prior_head_seq: u64,
14698 prior_feed_hash: Option<String>,
14699 statement: String,
14700}
14701
14702fn rotation_journal_path(key_path: &Path) -> PathBuf {
14703 let mut path = key_path.as_os_str().to_os_string();
14704 path.push(".rotation.json");
14705 PathBuf::from(path)
14706}
14707
14708fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
14709 #[cfg(unix)]
14710 let file = {
14711 use std::os::fd::{AsRawFd as _, FromRawFd as _};
14712 use std::os::unix::ffi::OsStrExt as _;
14713 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
14714 .map_err(|error| {
14715 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
14716 })?;
14717 let leaf_name = path
14718 .file_name()
14719 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
14720 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
14721 let fd = unsafe {
14722 libc::openat(
14723 parent.as_raw_fd(),
14724 leaf.as_ptr(),
14725 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14726 )
14727 };
14728 if fd < 0 {
14729 return Err(bad_agent_key(
14730 "the rotation journal must be an existing regular file without symlink ancestors",
14731 ));
14732 }
14733 unsafe { std::fs::File::from_raw_fd(fd) }
14734 };
14735 #[cfg(not(unix))]
14736 let file = std::fs::File::open(path)
14737 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
14738 let metadata = file
14739 .metadata()
14740 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
14741 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
14742 return Err(bad_agent_key(
14743 "the rotation journal must be a bounded regular file",
14744 ));
14745 }
14746 #[cfg(unix)]
14747 {
14748 use std::os::unix::fs::PermissionsExt as _;
14749 if metadata.permissions().mode() & 0o077 != 0 {
14750 return Err(bad_agent_key(
14751 "the rotation journal is accessible to group/other; set mode 0600",
14752 ));
14753 }
14754 }
14755 serde_json::from_reader(file)
14756 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
14757}
14758
14759fn remove_rotation_journal(path: &Path) {
14760 #[cfg(unix)]
14761 {
14762 use std::os::fd::AsRawFd as _;
14763 use std::os::unix::ffi::OsStrExt as _;
14764 let Ok(parent) =
14765 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
14766 else {
14767 return;
14768 };
14769 let Some(leaf_name) = path.file_name() else {
14770 return;
14771 };
14772 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
14773 return;
14774 };
14775 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
14776 let _ = parent.sync_all();
14777 }
14778 }
14779 #[cfg(not(unix))]
14780 {
14781 let _ = std::fs::remove_file(path);
14782 }
14783}
14784
14785fn validate_rotation_journal(
14786 journal: &RotationJournal,
14787 cfg: &HubConfig,
14788 canonical_brain: &str,
14789 old_key: &AgentSigningKey,
14790 new_key: &AgentSigningKey,
14791 head: &Head,
14792) -> LinkResult<()> {
14793 if journal.v != 1
14794 || journal.origin != normalized_origin(&cfg.hub)?
14795 || journal.brain != canonical_brain
14796 || journal.old_brain != old_key.multikey
14797 || journal.new_brain != new_key.multikey
14798 || journal.prior_head_seq != head.seq
14799 || journal.prior_feed_hash != head.feed_hash
14800 {
14801 return Err(invalid_feed(
14802 "rotation journal does not match the verified key and feed boundary",
14803 ));
14804 }
14805 let statement: RotationStatement = serde_json::from_str(&journal.statement)
14806 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
14807 if statement.prior_head_seq != journal.prior_head_seq
14808 || statement.prior_feed_hash != journal.prior_feed_hash
14809 || statement.brain != old_key.multikey
14810 || statement.public_key != old_key.public_key_spki
14811 || statement.new_brain != new_key.multikey
14812 || statement.new_public_key != new_key.public_key_spki
14813 {
14814 return Err(invalid_feed(
14815 "rotation journal statement does not match its durable intent",
14816 ));
14817 }
14818 let identity = FeedIdentity {
14819 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
14820 public_key_spki: new_key.public_key_spki.clone(),
14821 previous: vec![PreviousIdentity {
14822 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
14823 public_key_spki: old_key.public_key_spki.clone(),
14824 }],
14825 rotations: vec![journal.statement.clone()],
14826 };
14827 verify_identity_chain(&identity, None)?;
14828 Ok(())
14829}
14830
14831#[derive(Debug, Serialize)]
14833pub struct RotationReport {
14834 pub brain: String,
14836 pub multikey: String,
14838 #[serde(rename = "keyFile")]
14840 pub key_file: String,
14841 pub previous: Vec<String>,
14843}
14844
14845pub fn rotate_brain_key(
14851 cfg: &HubConfig,
14852 brain: &str,
14853 old_key: &AgentSigningKey,
14854 out: &Path,
14855) -> LinkResult<RotationReport> {
14856 require_hardened_filesystem("key rotation")?;
14857 require_safe_ref(brain)?;
14858 let new_key = if out.exists() {
14862 load_signing_key(out)?
14863 } else {
14864 let rng = ring::rand::SystemRandom::new();
14865 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
14866 .map_err(|_| bad_agent_key("key generation failed"))?;
14867 let pair = agent_keypair(pkcs8.as_ref())?;
14868 let (public_key_spki, multikey) = public_identity_for(&pair);
14869 write_secret_new(
14870 out,
14871 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
14872 )?;
14873 AgentSigningKey {
14874 pkcs8: pkcs8.as_ref().to_vec(),
14875 multikey,
14876 public_key_spki,
14877 }
14878 };
14879 let new_spki = new_key.public_key_spki.clone();
14880 let new_multikey = new_key.multikey.clone();
14881 let journal_path = rotation_journal_path(out);
14882 let before_v2 = v2_verified_head(cfg, brain)?;
14883 let (canonical_brain, served_identity, observed_head, v2_profile) =
14884 if let Some(head) = before_v2 {
14885 let observed = Head {
14886 brain: head.brain_id.clone(),
14887 seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14888 updated_at: head
14889 .pointer
14890 .as_ref()
14891 .map(|pointer| pointer.signed_at.clone()),
14892 feed_hash: head
14893 .pointer
14894 .as_ref()
14895 .map(|pointer| pointer.feed_hash.clone()),
14896 verified: true,
14897 };
14898 let identity = v2_identity(&head.identity);
14899 let canonical = head.brain_id.clone();
14900 accept_v2_head(cfg, &head)?;
14901 (canonical, identity, observed, true)
14902 } else {
14903 let remote = verified_remote_head(cfg, brain, false)?;
14904 let identity = remote
14905 .identity
14906 .clone()
14907 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
14908 (remote.head.brain.clone(), identity, remote.head, false)
14909 };
14910 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
14911 let already_rotated = served_multikey == new_multikey;
14912 if already_rotated && !journal_path.exists() {
14917 remove_rotation_journal(&journal_path);
14918 return Ok(RotationReport {
14919 brain: brain.to_string(),
14920 multikey: new_multikey,
14921 key_file: out.display().to_string(),
14922 previous: served_identity
14923 .previous
14924 .iter()
14925 .map(|identity| format!("ed25519:{}", identity.fingerprint))
14926 .collect(),
14927 });
14928 }
14929 if !already_rotated && served_multikey != old_key.multikey {
14930 return Err(invalid_feed(
14931 "the supplied old key is not the brain's verified current identity",
14932 ));
14933 }
14934
14935 let journal = if journal_path.exists() {
14936 read_rotation_journal(&journal_path)?
14937 } else {
14938 let ts = crate::now()
14939 .with_timezone(&chrono::Utc)
14940 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
14941 .to_string();
14942 let unsigned = serde_json::to_string(&UnsignedRotation {
14943 v: 1,
14944 op: "rotate",
14945 brain: &old_key.multikey,
14946 public_key: &old_key.public_key_spki,
14947 new_brain: &new_multikey,
14948 new_public_key: &new_spki,
14949 prior_head_seq: observed_head.seq,
14950 prior_feed_hash: observed_head.feed_hash.as_deref(),
14951 ts,
14952 })
14953 .expect("serialize rotation");
14954 let old_pair = agent_keypair(&old_key.pkcs8)?;
14955 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14956 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
14957 let journal = RotationJournal {
14958 v: 1,
14959 origin: normalized_origin(&cfg.hub)?,
14960 brain: canonical_brain.clone(),
14961 old_brain: old_key.multikey.clone(),
14962 new_brain: new_multikey.clone(),
14963 prior_head_seq: observed_head.seq,
14964 prior_feed_hash: observed_head.feed_hash.clone(),
14965 statement,
14966 };
14967 let mut exact = serde_json::to_vec(&journal)
14968 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
14969 exact.push(b'\n');
14970 if write_secret_new(&journal_path, &exact).is_err() {
14971 read_rotation_journal(&journal_path)?
14974 } else {
14975 journal
14976 }
14977 };
14978 validate_rotation_journal(
14979 &journal,
14980 cfg,
14981 &canonical_brain,
14982 old_key,
14983 &new_key,
14984 &observed_head,
14985 )?;
14986
14987 let body = json!({ "statement": journal.statement });
14988 let path = format!("/api/hub/brains/{brain}/rotate");
14989 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
14990 let attempted_failure = match attempted {
14991 Ok(response) if (200..300).contains(&response.status) => None,
14992 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
14993 Err(error) => Some(error),
14994 };
14995
14996 let identity = if v2_profile {
15000 match v2_verified_head(cfg, brain) {
15001 Ok(Some(after)) => {
15002 let identity = v2_identity(&after.identity);
15003 accept_v2_head(cfg, &after)?;
15004 identity
15005 }
15006 Ok(None) => {
15007 return Err(attempted_failure.unwrap_or_else(|| {
15008 invalid_feed("rotated v2 brain no longer serves a v2 head")
15009 }));
15010 }
15011 Err(error) => return Err(attempted_failure.unwrap_or(error)),
15012 }
15013 } else {
15014 match verified_remote_head(cfg, brain, false) {
15015 Ok(after) => after
15016 .identity
15017 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
15018 Err(error) => return Err(attempted_failure.unwrap_or(error)),
15019 }
15020 };
15021 if format!("ed25519:{}", identity.fingerprint) != new_multikey
15022 || identity.public_key_spki != new_spki
15023 {
15024 return Err(attempted_failure.unwrap_or_else(|| {
15025 invalid_feed("hub acknowledged rotation without committing the verified new identity")
15026 }));
15027 }
15028 if v2_profile {
15029 if let Some(error) = attempted_failure {
15030 return Err(error);
15035 }
15036 }
15037 let previous = identity
15038 .previous
15039 .iter()
15040 .map(|prior| format!("ed25519:{}", prior.fingerprint))
15041 .collect();
15042 remove_rotation_journal(&journal_path);
15043
15044 Ok(RotationReport {
15045 brain: brain.to_string(),
15046 multikey: new_multikey,
15047 key_file: out.display().to_string(),
15048 previous,
15049 })
15050}
15051
15052#[derive(Debug, Serialize)]
15058pub struct MirrorReport {
15059 pub brain: String,
15061 #[serde(rename = "headSeq")]
15063 pub head_seq: u64,
15064 #[serde(rename = "feedHash")]
15066 pub feed_hash: Option<String>,
15067 pub entries: u64,
15069 pub pinned: String,
15071 pub files: usize,
15073}
15074
15075pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
15077
15078#[derive(Debug)]
15080pub struct VerifiedMirrorMaterial {
15081 pub brain: String,
15082 pub head_seq: u64,
15083 pub feed_hash: Option<String>,
15084 pub identity: serde_json::Value,
15085 pub entries: Vec<(u64, String, String)>,
15087 pub pack_sha256: Option<String>,
15088}
15089
15090#[derive(Deserialize)]
15091#[serde(deny_unknown_fields)]
15092struct StoredMirrorHead {
15093 brain: String,
15094 #[serde(rename = "headSeq")]
15095 head_seq: u64,
15096 #[serde(rename = "feedHash")]
15097 feed_hash: Option<String>,
15098}
15099
15100pub fn verify_mirror_material(
15103 head_bytes: &[u8],
15104 identity_bytes: &[u8],
15105 feed_bytes: &[Vec<u8>],
15106 snapshot_pack: Option<&[u8]>,
15107 expected_anchor: &str,
15108) -> LinkResult<VerifiedMirrorMaterial> {
15109 let snapshot_hash = snapshot_pack
15110 .filter(|pack| !pack.is_empty())
15111 .map(content_sha256);
15112 verify_mirror_material_with_pack_hash(
15113 head_bytes,
15114 identity_bytes,
15115 feed_bytes,
15116 snapshot_hash.as_deref(),
15117 expected_anchor,
15118 )
15119}
15120
15121pub fn verify_mirror_material_with_pack_hash(
15125 head_bytes: &[u8],
15126 identity_bytes: &[u8],
15127 feed_bytes: &[Vec<u8>],
15128 snapshot_pack_sha256: Option<&str>,
15129 expected_anchor: &str,
15130) -> LinkResult<VerifiedMirrorMaterial> {
15131 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
15132 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
15133 require_safe_ref(&head.brain)?;
15134 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
15135 return Err(invalid_feed(
15136 "stored mirror feed count does not match its bounded head sequence",
15137 ));
15138 }
15139 let aggregate = feed_bytes
15140 .iter()
15141 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
15142 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
15143 if aggregate > MAX_FEED_REPLAY_BYTES {
15144 return Err(invalid_feed(
15145 "stored mirror feed metadata exceeds the aggregate limit",
15146 ));
15147 }
15148 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
15149 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
15150 let anchor = verify_identity_chain(&identity, None)?;
15151 if anchor != expected_anchor {
15152 return Err(invalid_feed(
15153 "stored mirror identity does not descend from the explicitly trusted anchor",
15154 ));
15155 }
15156
15157 let mut entries = Vec::with_capacity(feed_bytes.len());
15158 let mut items = Vec::with_capacity(feed_bytes.len());
15159 let mut previous_hash = None;
15160 let mut pack_sha256 = None;
15161 for (index, bytes) in feed_bytes.iter().enumerate() {
15162 let exact = bytes
15163 .strip_suffix(b"\n")
15164 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
15165 if exact.ends_with(b"\n") {
15166 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
15167 }
15168 let entry: FeedEntry = serde_json::from_slice(exact)
15169 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
15170 let expected_seq = index as u64 + 1;
15171 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
15172 return Err(invalid_feed(
15173 "stored mirror feed is not contiguous and hash-chained",
15174 ));
15175 }
15176 let canonical = serde_json::to_vec(&entry)
15177 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
15178 if canonical != exact {
15179 return Err(invalid_feed(
15180 "stored feed entry is not in normative serialization",
15181 ));
15182 }
15183 let hash = content_sha256(bytes);
15184 let item = FeedItem {
15185 hash: hash.clone(),
15186 entry,
15187 };
15188 verify_feed_item(&item, &identity)?;
15189 previous_hash = Some(hash.clone());
15190 if expected_seq == head.head_seq {
15191 pack_sha256 = Some(item.entry.pack_sha256.clone());
15192 }
15193 entries.push((
15194 expected_seq,
15195 std::str::from_utf8(exact)
15196 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
15197 .to_string(),
15198 hash,
15199 ));
15200 items.push(item);
15201 }
15202 if previous_hash != head.feed_hash {
15203 return Err(invalid_feed(
15204 "stored mirror feed does not converge on its advertised head",
15205 ));
15206 }
15207 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
15208 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
15209 (0, None, None) => {}
15210 (_, Some(actual), Some(expected)) if actual == expected => {}
15211 _ => {
15212 return Err(LinkError::InvalidPack {
15213 message: "stored snapshot pack does not match the signed head digest".to_string(),
15214 });
15215 }
15216 }
15217 let identity_value = serde_json::to_value(&identity)
15218 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
15219 Ok(VerifiedMirrorMaterial {
15220 brain: head.brain,
15221 head_seq: head.head_seq,
15222 feed_hash: head.feed_hash,
15223 identity: identity_value,
15224 entries,
15225 pack_sha256,
15226 })
15227}
15228
15229pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
15232 format!(
15233 "{:x}",
15234 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
15235 )
15236}
15237
15238pub fn content_sha256(bytes: &[u8]) -> String {
15241 format!("{:x}", Sha256::digest(bytes))
15242}
15243
15244pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
15246 let mut digest = Sha256::new();
15247 let mut buffer = [0u8; 64 * 1024];
15248 loop {
15249 let read = reader.read(&mut buffer)?;
15250 if read == 0 {
15251 break;
15252 }
15253 digest.update(&buffer[..read]);
15254 }
15255 Ok(format!("{:x}", digest.finalize()))
15256}
15257
15258#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
15266pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
15267 require_hardened_filesystem("mirror")?;
15268 require_safe_ref(brain)?;
15269 #[cfg(windows)]
15270 {
15271 let _ = (cfg, dest);
15272 return Err(LinkError::UnsupportedPlatform {
15273 operation: "atomic whole-mirror replacement on Windows",
15274 });
15275 }
15276 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
15277 let name = dest
15278 .file_name()
15279 .and_then(|name| name.to_str())
15280 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
15281 .ok_or_else(|| LinkError::UnsafePath {
15282 path: dest.display().to_string(),
15283 })?;
15284 #[cfg(unix)]
15285 let parent_dir = open_or_create_dir_nofollow(parent)?;
15286 #[cfg(unix)]
15287 use std::os::fd::AsRawFd as _;
15288 #[cfg(unix)]
15289 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
15290 #[cfg(unix)]
15291 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
15292 None => false,
15293 Some(true) => true,
15294 Some(false) => {
15295 return Err(LinkError::UnsafePath {
15296 path: dest.display().to_string(),
15297 });
15298 }
15299 };
15300
15301 #[cfg(unix)]
15304 let legacy_backup_name = c_name(
15305 format!(".{name}.dbmd-backup").as_bytes(),
15306 &dest.display().to_string(),
15307 )?;
15308 #[cfg(unix)]
15309 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
15310 return Err(LinkError::UnsafePath {
15311 path: parent
15312 .join(format!(".{name}.dbmd-backup"))
15313 .display()
15314 .to_string(),
15315 });
15316 }
15317
15318 let nonce = std::time::SystemTime::now()
15319 .duration_since(std::time::UNIX_EPOCH)
15320 .unwrap_or_default()
15321 .as_nanos();
15322 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
15323 #[cfg(unix)]
15324 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
15325 #[cfg(unix)]
15326 let stage_dir = create_dir_exclusive_at(
15327 parent_dir.as_raw_fd(),
15328 &stage_name,
15329 &dest.display().to_string(),
15330 )?;
15331
15332 let assembled = (|| -> LinkResult<MirrorReport> {
15333 let remote = verified_remote_head(cfg, brain, true)?;
15334 let brain_id = remote.head.brain.clone();
15335 let identity = remote
15336 .identity
15337 .as_ref()
15338 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
15339 let anchor = remote
15340 .anchor
15341 .clone()
15342 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
15343 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
15344 let snapshot_entries = parse_store_pack(pack.clone())?;
15345 let snapshot_count = snapshot_entries.len();
15346 let mut staged_entries = snapshot_entries;
15347 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
15348 for item in &remote.entries {
15349 let mut exact = serde_json::to_vec(&item.entry)
15350 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
15351 exact.push(b'\n');
15352 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
15353 return Err(invalid_feed(
15354 "serialized mirror entry differs from its verified hash",
15355 ));
15356 }
15357 staged_entries.push((
15358 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
15359 exact,
15360 ));
15361 }
15362 let mut identity_bytes = serde_json::to_vec(identity)
15363 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
15364 identity_bytes.push(b'\n');
15365 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
15366 let mut head_bytes = serde_json::to_vec(&json!({
15367 "brain": brain_id,
15368 "headSeq": remote.head.seq,
15369 "feedHash": remote.head.feed_hash,
15370 }))
15371 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
15372 head_bytes.push(b'\n');
15373 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
15374 staged_entries.push((
15375 CONFIG_REL_PATH.to_string(),
15376 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
15377 ));
15378 #[cfg(unix)]
15379 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
15380
15381 Ok(MirrorReport {
15382 brain: brain_id,
15383 head_seq: remote.head.seq,
15384 feed_hash: remote.head.feed_hash,
15385 entries: remote.entries.len() as u64,
15386 pinned: anchor,
15387 files: snapshot_count,
15388 })
15389 })();
15390
15391 let report = match assembled {
15392 Ok(report) => report,
15393 Err(error) => {
15394 #[cfg(unix)]
15395 let _ = remove_tree_at(
15396 parent_dir.as_raw_fd(),
15397 &stage_name,
15398 &dest.display().to_string(),
15399 );
15400 return Err(error);
15401 }
15402 };
15403
15404 #[cfg(unix)]
15405 if let Err(error) =
15406 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
15407 {
15408 let _ = remove_tree_at(
15409 parent_dir.as_raw_fd(),
15410 &stage_name,
15411 &dest.display().to_string(),
15412 );
15413 return Err(error);
15414 }
15415 #[cfg(unix)]
15418 if dest_exists {
15419 remove_tree_at(
15420 parent_dir.as_raw_fd(),
15421 &stage_name,
15422 &dest.display().to_string(),
15423 )?;
15424 }
15425 #[cfg(unix)]
15426 parent_dir.sync_all()?;
15427 Ok(report)
15428}
15429
15430fn verified_remote_head(
15431 cfg: &HubConfig,
15432 brain: &str,
15433 require_full_chain: bool,
15434) -> LinkResult<VerifiedRemote> {
15435 require_hardened_filesystem("verified link.md state")?;
15436 require_safe_ref(brain)?;
15437 let trust_directory = open_trust_dir(cfg)?;
15441 let path = format!("/api/hub/brains/{brain}");
15442 let body = ensure_ok(
15443 request(cfg, "GET", &path, None, Auth::Required)?,
15444 "subscribe",
15445 )?;
15446 let resolved_brain = body
15447 .get("id")
15448 .and_then(Value::as_str)
15449 .filter(|id| crate::ulid::is_ulid(id))
15450 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
15451 .to_string();
15452 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
15453 return Err(invalid_feed(
15454 "brain card id differs from the explicitly requested brain id",
15455 ));
15456 }
15457 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
15462 let (pinned, alias_binding) =
15463 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
15464 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
15465 let advertised_hash = body
15466 .get("feedHash")
15467 .and_then(Value::as_str)
15468 .map(str::to_string);
15469 let updated_at = body
15470 .get("updatedAt")
15471 .and_then(Value::as_str)
15472 .map(str::to_string);
15473 if let Some(pin) = &pinned {
15474 if seq < pin.head_seq {
15475 return Err(invalid_feed(format!(
15476 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
15477 pin.head_seq
15478 )));
15479 }
15480 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
15481 return Err(invalid_feed(
15482 "feed equivocation: the checkpoint sequence now has a different hash",
15483 ));
15484 }
15485 }
15486 if seq == 0 {
15487 if advertised_hash.is_some() {
15488 return Err(invalid_feed("an empty feed advertised a head hash"));
15489 }
15490 let identity: FeedIdentity = serde_json::from_value(
15491 body.get("identity")
15492 .cloned()
15493 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
15494 )
15495 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
15496 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
15497 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
15502 save_canonical_pin_and_alias(
15503 cfg,
15504 &trust_directory,
15505 brain,
15506 &resolved_brain,
15507 TrustState {
15508 v: 2,
15509 origin: normalized_origin(&cfg.hub)?,
15510 requested: resolved_brain.clone(),
15511 brain: resolved_brain.clone(),
15512 home: None,
15513 anchor: anchor.clone(),
15514 current: format!("ed25519:{}", identity.fingerprint),
15515 head_seq: 0,
15516 feed_hash: None,
15517 rotations: identity.rotations.clone(),
15518 hub_signer: None,
15519 protocol_profile: None,
15520 },
15521 alias_binding.as_ref(),
15522 )?;
15523 return Ok(VerifiedRemote {
15524 head: Head {
15525 brain: resolved_brain,
15526 seq,
15527 updated_at,
15528 feed_hash: None,
15529 verified: true,
15530 },
15531 identity: Some(identity),
15532 head_entry: None,
15533 entries: Vec::new(),
15534 anchor: Some(anchor),
15535 });
15536 }
15537 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
15538 return Err(invalid_feed(
15539 "non-empty feed did not advertise a valid SHA-256 head",
15540 ));
15541 }
15542
15543 let replay_head_only = !require_full_chain
15547 && pinned
15548 .as_ref()
15549 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
15550 let mut after = if replay_head_only {
15551 seq - 1
15552 } else if require_full_chain || pinned.is_none() {
15553 0
15554 } else {
15555 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
15556 };
15557 let mut expected_seq = after + 1;
15558 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
15559 None
15560 } else {
15561 pinned
15562 .as_ref()
15563 .and_then(|checkpoint| checkpoint.feed_hash.clone())
15564 };
15565 let mut identity: Option<FeedIdentity> = None;
15566 let mut anchor: Option<String> = None;
15567 let mut head_entry: Option<FeedItem> = None;
15568 let mut all_entries = Vec::new();
15569 let mut observed_entries = Vec::new();
15570 let replay_count = seq
15571 .checked_sub(after)
15572 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
15573 if replay_count > MAX_FEED_REPLAY_ENTRIES {
15574 return Err(invalid_feed(format!(
15575 "feed replay requires {replay_count} entries, over the client cap"
15576 )));
15577 }
15578 let mut replay_bytes = 0u64;
15579
15580 loop {
15581 let feed_bytes = ensure_raw_ok(
15582 request_raw(
15583 cfg,
15584 "GET",
15585 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
15586 None,
15587 Auth::Required,
15588 MAX_FEED_RESPONSE_BYTES,
15589 )?,
15590 "subscribe feed",
15591 )?;
15592 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
15593 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
15594 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
15595 return Err(invalid_feed("brain card and feed head disagree"));
15596 }
15597 if feed.entries.len() > FEED_PAGE_LIMIT {
15598 return Err(invalid_feed("feed page exceeds the requested entry limit"));
15599 }
15600 if feed.scope_limited {
15601 if require_full_chain {
15602 return Err(invalid_feed(
15603 "path-scoped grants cannot verify a full snapshot chain",
15604 ));
15605 }
15606 return Ok(VerifiedRemote {
15607 head: Head {
15608 brain: resolved_brain,
15609 seq,
15610 updated_at,
15611 feed_hash: advertised_hash,
15612 verified: false,
15613 },
15614 identity: None,
15615 head_entry: None,
15616 entries: Vec::new(),
15617 anchor: None,
15618 });
15619 }
15620 let page_identity = feed
15621 .identity
15622 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
15623 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
15624 if identity
15625 .as_ref()
15626 .is_some_and(|existing| existing != &page_identity)
15627 {
15628 return Err(invalid_feed("identity changed while reading the feed"));
15629 }
15630 if anchor
15631 .as_ref()
15632 .is_some_and(|existing| existing != &page_anchor)
15633 {
15634 return Err(invalid_feed(
15635 "identity anchor changed while reading the feed",
15636 ));
15637 }
15638 identity = Some(page_identity.clone());
15639 if anchor.is_none() {
15640 anchor = Some(page_anchor);
15641 }
15642 if feed.entries.is_empty() {
15643 return Err(invalid_feed("feed page was empty before the signed head"));
15644 }
15645
15646 for item in feed.entries {
15647 if item.entry.seq != expected_seq {
15648 return Err(invalid_feed(format!(
15649 "expected entry {expected_seq}, feed served {}",
15650 item.entry.seq
15651 )));
15652 }
15653 if item.entry.seq > seq {
15654 return Err(invalid_feed("feed advanced past the card snapshot"));
15655 }
15656 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
15657 return Err(invalid_feed(format!(
15658 "entry {} does not chain to the local checkpoint",
15659 item.entry.seq
15660 )));
15661 }
15662 verify_feed_item(&item, &page_identity)?;
15663 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
15664 replay_bytes = replay_bytes.saturating_add(
15665 serde_json::to_vec(&item)
15666 .map_err(|_| invalid_feed("could not size feed entry"))?
15667 .len() as u64,
15668 );
15669 if replay_bytes > MAX_FEED_REPLAY_BYTES {
15670 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
15671 }
15672 previous_hash = Some(item.hash.clone());
15673 after = item.entry.seq;
15674 expected_seq = expected_seq
15675 .checked_add(1)
15676 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
15677 if require_full_chain {
15678 all_entries.push(item.clone());
15679 }
15680 observed_entries.push(item.clone());
15681 head_entry = Some(item);
15682 }
15683 if after == seq {
15684 break;
15685 }
15686 }
15687
15688 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
15689 return Err(invalid_feed(
15690 "verified chain does not converge on the advertised head",
15691 ));
15692 }
15693 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
15694 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
15695 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
15696 save_canonical_pin_and_alias(
15697 cfg,
15698 &trust_directory,
15699 brain,
15700 &resolved_brain,
15701 TrustState {
15702 v: 2,
15703 origin: normalized_origin(&cfg.hub)?,
15704 requested: resolved_brain.clone(),
15705 brain: resolved_brain.clone(),
15706 home: None,
15707 anchor: anchor.clone(),
15708 current: format!("ed25519:{}", identity.fingerprint),
15709 head_seq: seq,
15710 feed_hash: advertised_hash.clone(),
15711 rotations: identity.rotations.clone(),
15712 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
15713 protocol_profile: pinned
15714 .as_ref()
15715 .and_then(|state| state.protocol_profile.clone()),
15716 },
15717 alias_binding.as_ref(),
15718 )?;
15719 Ok(VerifiedRemote {
15720 head: Head {
15721 brain: resolved_brain,
15722 seq,
15723 updated_at,
15724 feed_hash: advertised_hash,
15725 verified: true,
15726 },
15727 identity: Some(identity),
15728 head_entry,
15729 entries: all_entries,
15730 anchor: Some(anchor),
15731 })
15732}
15733
15734pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
15739 if let Some(verified) = v2_verified_head(cfg, brain)? {
15740 let observation = Head {
15741 brain: verified.brain_id.clone(),
15742 seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
15743 updated_at: verified
15744 .pointer
15745 .as_ref()
15746 .map(|pointer| pointer.signed_at.clone()),
15747 feed_hash: verified
15748 .pointer
15749 .as_ref()
15750 .map(|pointer| pointer.feed_hash.clone()),
15751 verified: true,
15752 };
15753 accept_v2_head(cfg, &verified)?;
15754 return Ok(observation);
15755 }
15756 Ok(verified_remote_head(cfg, brain, false)?.head)
15757}
15758
15759#[cfg(test)]
15760mod tests {
15761 use super::*;
15762
15763 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
15764
15765 fn drain_test_http_request(stream: &mut std::net::TcpStream) {
15766 use std::io::Read as _;
15767
15768 stream
15769 .set_read_timeout(Some(std::time::Duration::from_secs(2)))
15770 .unwrap();
15771 let mut request = Vec::new();
15772 let mut chunk = [0_u8; 4096];
15773 loop {
15774 let read = stream.read(&mut chunk).unwrap();
15775 assert!(read > 0, "test client closed before its request completed");
15776 request.extend_from_slice(&chunk[..read]);
15777 let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") else {
15778 continue;
15779 };
15780 let headers = String::from_utf8_lossy(&request[..header_end]);
15781 let content_length = headers
15782 .lines()
15783 .find_map(|line| {
15784 let (name, value) = line.split_once(':')?;
15785 name.eq_ignore_ascii_case("content-length")
15786 .then(|| value.trim().parse::<usize>().unwrap())
15787 })
15788 .unwrap_or(0);
15789 if request.len() >= header_end + 4 + content_length {
15790 return;
15791 }
15792 }
15793 }
15794
15795 fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
15796 json!({
15797 "sha256": "a".repeat(64),
15798 "bytes": 10,
15799 "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
15800 })
15801 }
15802
15803 #[test]
15804 fn upload_reservations_batch_by_count_and_by_size() {
15805 let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
15809 let batches = batch_upload_declarations(declarations.clone());
15810
15811 assert!(batches.len() > 1, "5,000 blobs must not ride one request");
15812 for batch in &batches {
15813 assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
15814 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15815 .expect("batch serializes")
15816 .len();
15817 assert!(
15818 bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
15819 "batch body {bytes} exceeds the reservation budget"
15820 );
15821 }
15822 let flattened: Vec<Value> = batches.into_iter().flatten().collect();
15823 assert_eq!(
15824 flattened, declarations,
15825 "batching must preserve the set and order"
15826 );
15827 }
15828
15829 #[test]
15830 fn only_load_shaped_hub_answers_are_worth_asking_again() {
15831 for status in [408, 429, 500, 502, 503, 504] {
15836 assert!(is_retryable_hub_status(status), "{status} is load-shaped");
15837 }
15838 for status in [400, 401, 403, 404, 409, 413, 422] {
15839 assert!(
15840 !is_retryable_hub_status(status),
15841 "{status} states something about the request"
15842 );
15843 }
15844 let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
15846 assert!(total >= 60_000, "backoff totals only {total}ms");
15847 }
15848
15849 #[test]
15850 fn a_batch_shares_a_connection_only_within_one_authority() {
15851 let cfg = HubConfig {
15856 hub: "https://www.sevrahq.com".to_string(),
15857 key: Some("k".to_string()),
15858 agent_key: None,
15859 brain_key: None,
15860 state_dir: PathBuf::from("."),
15861 store_selected: false,
15862 };
15863 assert!(shared_staging_agent(&cfg, &[]).is_none());
15864 assert!(
15865 shared_staging_agent(
15866 &cfg,
15867 &[
15868 "https://one.example.com/a?sig=1",
15869 "https://two.example.com/b?sig=2",
15870 ]
15871 )
15872 .is_none(),
15873 "two authorities must not share a pinned pool"
15874 );
15875 assert!(
15876 shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
15877 "an unsafe object-store URL must not produce an agent"
15878 );
15879 assert!(
15880 shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
15881 "credentials in the URL must not produce an agent"
15882 );
15883 }
15884
15885 #[test]
15886 fn a_staged_change_states_only_operations_and_blobs() {
15887 let operations = vec![json!({
15891 "op": "put",
15892 "path": "records/a.md",
15893 "blob": "a".repeat(64),
15894 "bytes": 3,
15895 })];
15896 let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
15897 let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
15898 let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
15899 let keys: Vec<&str> = parsed
15900 .as_object()
15901 .expect("manifest is an object")
15902 .keys()
15903 .map(String::as_str)
15904 .collect();
15905 assert_eq!(keys, ["blobs", "operations"]);
15906 assert_eq!(parsed["operations"], Value::Array(operations));
15907 assert_eq!(parsed["blobs"], blobs);
15908 }
15909
15910 #[test]
15911 fn a_staged_push_signs_the_change_not_the_transport() {
15912 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15917 let staged = json!({
15918 "mutation_id": "dbmd-1",
15919 "rebase": "strict",
15920 "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
15921 });
15922 let view = v2_signed_request_view(&staged, &operations);
15923 assert_eq!(view["operations"], Value::Array(operations.clone()));
15924 assert!(view.get("staged_change").is_none());
15925 assert_eq!(view["mutation_id"], staged["mutation_id"]);
15926
15927 let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
15928 assert_eq!(v2_signed_request_view(&inline, &[]), inline);
15929 }
15930
15931 #[test]
15932 fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
15933 let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
15934 let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
15935 .expect_err("an oversized change must not be staged");
15936 assert!(
15937 matches!(error, LinkError::PushTooLarge { .. }),
15938 "expected a size refusal, got {error:?}"
15939 );
15940 }
15941
15942 #[test]
15943 fn a_push_that_fits_the_request_is_left_inline() {
15944 let cfg = HubConfig {
15948 hub: "http://127.0.0.1:9".to_string(),
15949 key: Some("k".to_string()),
15950 agent_key: None,
15951 brain_key: None,
15952 state_dir: PathBuf::from("."),
15953 store_selected: false,
15954 };
15955 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15956 let mut body = json!({
15957 "mutation_id": "dbmd-1",
15958 "operations": operations,
15959 "blobs": [],
15960 });
15961 stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
15962 assert!(body.get("staged_change").is_none());
15963 assert_eq!(body["operations"], Value::Array(operations));
15964 }
15965
15966 #[test]
15967 fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
15968 let declarations: Vec<Value> = (0..2_000)
15972 .map(|index| {
15973 json!({
15974 "sha256": "a".repeat(64),
15975 "bytes": 10,
15976 "coordinates": (0..24)
15977 .map(|slot| format!(
15978 "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
15979 ))
15980 .collect::<Vec<_>>(),
15981 })
15982 })
15983 .collect();
15984 let batches = batch_upload_declarations(declarations);
15985 assert!(
15986 batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
15987 "wide coordinate sets must bound the batch by size"
15988 );
15989 for batch in &batches {
15990 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15991 .expect("batch serializes")
15992 .len();
15993 assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
15994 }
15995 }
15996
15997 #[test]
15998 fn a_small_push_still_rides_exactly_one_request() {
15999 let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
16000 assert_eq!(batch_upload_declarations(declarations).len(), 1);
16001 assert!(batch_upload_declarations(Vec::new()).is_empty());
16002 }
16003
16004 #[test]
16005 fn exact_source_move_becomes_one_provenance_preserving_rename() {
16006 let hash = "a".repeat(64);
16007 let operations = vec![
16008 json!({
16009 "op": "put",
16010 "path": "sources/curated/item.md",
16011 "expected": { "kind": "absent" },
16012 "blob": hash,
16013 "bytes": 19,
16014 }),
16015 json!({
16016 "op": "delete",
16017 "path": "sources/inbox/item.md",
16018 "expected": { "kind": "blob", "hash": hash },
16019 }),
16020 ];
16021
16022 assert_eq!(
16023 infer_exact_source_promotions(operations),
16024 vec![json!({
16025 "op": "rename",
16026 "from": "sources/inbox/item.md",
16027 "to": "sources/curated/item.md",
16028 "expected_from": { "kind": "blob", "hash": hash },
16029 "expected_to": { "kind": "absent" },
16030 "blob": hash,
16031 "bytes": 19,
16032 })]
16033 );
16034 }
16035
16036 #[test]
16037 fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
16038 let hash = "b".repeat(64);
16039 let operations = vec![
16040 json!({
16041 "op": "delete",
16042 "path": "sources/inbox/a.md",
16043 "expected": { "kind": "blob", "hash": hash },
16044 }),
16045 json!({
16046 "op": "delete",
16047 "path": "sources/inbox/b.md",
16048 "expected": { "kind": "blob", "hash": hash },
16049 }),
16050 json!({
16051 "op": "put",
16052 "path": "sources/curated/item.md",
16053 "expected": { "kind": "absent" },
16054 "blob": hash,
16055 "bytes": 19,
16056 }),
16057 ];
16058
16059 assert_eq!(
16060 infer_exact_source_promotions(operations.clone()),
16061 operations,
16062 "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
16063 );
16064 }
16065
16066 #[test]
16067 fn an_exact_dual_plane_source_move_remains_a_provenance_preserving_rename() {
16068 let hash = "c".repeat(64);
16069 let operations = vec![
16070 json!({
16071 "op": "delete",
16072 "path": "sources/inbox/item.md",
16073 "expected": { "kind": "blob", "hash": hash },
16074 }),
16075 json!({
16076 "op": "put_asset_content",
16077 "path": "sources/archive/item.md",
16078 "expected": { "kind": "absent" },
16079 "blob": hash,
16080 "bytes": 19,
16081 }),
16082 ];
16083 assert_eq!(
16084 infer_exact_source_promotions(operations),
16085 vec![json!({
16086 "op": "rename",
16087 "from": "sources/inbox/item.md",
16088 "to": "sources/archive/item.md",
16089 "expected_from": { "kind": "blob", "hash": hash },
16090 "expected_to": { "kind": "absent" },
16091 "blob": hash,
16092 "bytes": 19,
16093 })]
16094 );
16095 }
16096
16097 #[test]
16098 fn accepted_source_promotion_advances_the_local_baseline_exactly() {
16099 let hash = "c".repeat(64);
16100 let mut candidate = std::collections::BTreeMap::from([(
16101 "sources/inbox/item.md".to_string(),
16102 V2BaselineFile {
16103 sha256: hash.clone(),
16104 bytes: 19,
16105 proof: None,
16106 },
16107 )]);
16108 let mut candidate_assets = std::collections::BTreeMap::new();
16109 let operations = vec![
16110 json!({
16111 "op": "rename",
16112 "from": "sources/inbox/item.md",
16113 "to": "sources/curated/item.md",
16114 "expected_from": { "kind": "blob", "hash": hash },
16115 "expected_to": { "kind": "absent" },
16116 "blob": hash,
16117 "bytes": 19,
16118 }),
16119 json!({
16120 "op": "put",
16121 "path": "records/rsvps/item.md",
16122 "expected": { "kind": "absent" },
16123 "blob": "d".repeat(64),
16124 "bytes": 23,
16125 }),
16126 ];
16127
16128 assert!(!apply_generated_v2_operations(
16129 &operations,
16130 &std::collections::BTreeMap::new(),
16131 &mut candidate,
16132 &mut candidate_assets,
16133 )
16134 .unwrap());
16135 assert!(!candidate.contains_key("sources/inbox/item.md"));
16136 assert_eq!(
16137 candidate
16138 .get("sources/curated/item.md")
16139 .map(|file| (&file.sha256, file.bytes)),
16140 Some((&hash, 19))
16141 );
16142 assert_eq!(
16143 candidate
16144 .get("records/rsvps/item.md")
16145 .map(|file| (file.sha256.as_str(), file.bytes)),
16146 Some((
16147 "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
16148 23
16149 ))
16150 );
16151 }
16152
16153 fn merge_fixture(
16154 base: Option<&str>,
16155 remote: Option<&str>,
16156 local: Option<&str>,
16157 keep_local: bool,
16158 ) -> V2PulledMerge<String> {
16159 let map = |value: Option<&str>| {
16160 value
16161 .map(|value| [("records/a.md".to_string(), value.to_string())])
16162 .into_iter()
16163 .flatten()
16164 .collect::<std::collections::BTreeMap<_, _>>()
16165 };
16166 merge_v2_pulled_records(
16167 &map(base),
16168 &map(remote),
16169 &map(local),
16170 |value, _| value.clone(),
16171 |value, _| value.clone(),
16172 |_| keep_local,
16173 )
16174 }
16175
16176 #[test]
16177 fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
16178 let path = "records/a.md".to_string();
16179
16180 let local_add = merge_fixture(None, None, Some("local"), false);
16181 assert_eq!(
16182 local_add.records.get(&path).map(String::as_str),
16183 Some("local")
16184 );
16185 assert!(local_add.accept_remote.is_empty());
16186 assert!(local_add.conflicts.is_empty());
16187
16188 let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
16189 assert_eq!(
16190 local_edit.records.get(&path).map(String::as_str),
16191 Some("local")
16192 );
16193 assert!(local_edit.accept_remote.is_empty());
16194 assert!(local_edit.conflicts.is_empty());
16195
16196 let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
16197 assert!(!local_delete.records.contains_key(&path));
16198 assert!(local_delete.accept_remote.is_empty());
16199 assert!(local_delete.conflicts.is_empty());
16200
16201 let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
16202 assert_eq!(
16203 remote_edit.records.get(&path).map(String::as_str),
16204 Some("remote")
16205 );
16206 assert!(remote_edit.accept_remote.contains(&path));
16207 assert!(remote_edit.conflicts.is_empty());
16208
16209 let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
16210 assert!(!remote_delete.records.contains_key(&path));
16211 assert!(remote_delete.accept_remote.contains(&path));
16212 assert!(remote_delete.conflicts.is_empty());
16213
16214 let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
16215 assert_eq!(
16216 same_edit.records.get(&path).map(String::as_str),
16217 Some("same")
16218 );
16219 assert!(same_edit.accept_remote.contains(&path));
16220 assert!(same_edit.conflicts.is_empty());
16221
16222 let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
16223 assert_eq!(conflict.conflicts, vec![path.clone()]);
16224 assert_eq!(
16225 conflict.records.get(&path).map(String::as_str),
16226 Some("local")
16227 );
16228 assert!(conflict.accept_remote.is_empty());
16229
16230 let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
16231 assert_eq!(
16232 kept_home.records.get(&path).map(String::as_str),
16233 Some("local")
16234 );
16235 assert!(kept_home.accept_remote.is_empty());
16236 assert!(kept_home.conflicts.is_empty());
16237 }
16238
16239 #[test]
16240 fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
16241 let path = "sources/report.pdf";
16242 let record = crate::AssetRecord {
16243 path: path.to_string(),
16244 sha256: "a".repeat(64),
16245 bytes: 42,
16246 media_type: "application/pdf".to_string(),
16247 wrappers: vec!["gzip".to_string()],
16248 required: true,
16249 };
16250 let mut remote = V2BaselineAsset {
16251 blob_sha256: record.sha256.clone(),
16252 bytes: record.bytes,
16253 media_type: record.media_type.clone(),
16254 wrappers: record.wrappers.clone(),
16255 required: record.required,
16256 disposition: "withheld".to_string(),
16257 leaf_hash: "b".repeat(64),
16258 };
16259
16260 assert!(v2_asset_resumes_hosting(
16261 Some(&remote),
16262 path,
16263 &record,
16264 "hosted"
16265 ));
16266 assert!(!v2_asset_resumes_hosting(
16267 Some(&remote),
16268 path,
16269 &record,
16270 "withheld"
16271 ));
16272
16273 remote.disposition = "hosted".to_string();
16274 assert!(!v2_asset_resumes_hosting(
16275 Some(&remote),
16276 path,
16277 &record,
16278 "hosted"
16279 ));
16280
16281 remote.disposition = "withheld".to_string();
16282 remote.blob_sha256 = "c".repeat(64);
16283 assert!(!v2_asset_resumes_hosting(
16284 Some(&remote),
16285 path,
16286 &record,
16287 "hosted"
16288 ));
16289 assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
16290 }
16291
16292 #[test]
16293 fn v2_fresh_clone_preserves_only_exact_inherited_withheld_asset_absence() {
16294 let path = "sources/report.pdf";
16295 let record = crate::AssetRecord {
16296 path: path.to_string(),
16297 sha256: "a".repeat(64),
16298 bytes: 42,
16299 media_type: "application/pdf".to_string(),
16300 wrappers: vec!["records/report.md".to_string()],
16301 required: true,
16302 };
16303 let mut base = V2BaselineAsset {
16304 blob_sha256: record.sha256.clone(),
16305 bytes: record.bytes,
16306 media_type: record.media_type.clone(),
16307 wrappers: record.wrappers.clone(),
16308 required: record.required,
16309 disposition: "withheld".to_string(),
16310 leaf_hash: "b".repeat(64),
16311 };
16312
16313 assert!(v2_asset_inherits_withheld_absence(
16314 Some(&base),
16315 Some(&record),
16316 Some(&record),
16317 false,
16318 ));
16319 assert!(!v2_asset_inherits_withheld_absence(
16320 Some(&base),
16321 Some(&record),
16322 Some(&record),
16323 true,
16324 ));
16325
16326 base.disposition = "hosted".to_string();
16327 assert!(!v2_asset_inherits_withheld_absence(
16328 Some(&base),
16329 Some(&record),
16330 Some(&record),
16331 false,
16332 ));
16333
16334 base.disposition = "withheld".to_string();
16335 let mut changed = record.clone();
16336 changed.bytes += 1;
16337 assert!(!v2_asset_inherits_withheld_absence(
16338 Some(&base),
16339 Some(&record),
16340 Some(&changed),
16341 false,
16342 ));
16343 assert!(!v2_asset_inherits_withheld_absence(
16344 None,
16345 None,
16346 Some(&record),
16347 false,
16348 ));
16349 }
16350
16351 #[test]
16352 fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
16353 let path = "records/team/alpha.md".to_string();
16354 let deleted_path = "records/team/deleted.md".to_string();
16355 let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
16356 sha256,
16357 bytes,
16358 file: None,
16359 };
16360 let files = vec![
16361 V2ConflictFile {
16362 path: path.clone(),
16363 base: coordinate(None, None),
16364 local: coordinate(Some("b".repeat(64)), Some(7)),
16365 remote: coordinate(Some("a".repeat(64)), Some(5)),
16366 },
16367 V2ConflictFile {
16368 path: deleted_path.clone(),
16369 base: coordinate(Some("c".repeat(64)), Some(9)),
16370 local: coordinate(Some("d".repeat(64)), Some(11)),
16371 remote: coordinate(None, None),
16372 },
16373 ];
16374 let proven = V2BaselineFile {
16375 sha256: "a".repeat(64),
16376 bytes: 5,
16377 proof: None,
16378 };
16379 let current = [(path.clone(), proven.clone())]
16380 .into_iter()
16381 .collect::<std::collections::BTreeMap<_, _>>();
16382
16383 let (selected, deleted) = v2_take_remote_selection(&files, ¤t).unwrap();
16384 assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
16385 assert_eq!(deleted, vec![deleted_path.clone()]);
16386
16387 let changed = [(
16388 path.clone(),
16389 V2BaselineFile {
16390 sha256: "e".repeat(64),
16391 bytes: 5,
16392 proof: None,
16393 },
16394 )]
16395 .into_iter()
16396 .collect::<std::collections::BTreeMap<_, _>>();
16397 assert!(v2_take_remote_selection(&files, &changed).is_err());
16398
16399 let resurrected = [
16400 (path, proven),
16401 (
16402 deleted_path,
16403 V2BaselineFile {
16404 sha256: "f".repeat(64),
16405 bytes: 13,
16406 proof: None,
16407 },
16408 ),
16409 ]
16410 .into_iter()
16411 .collect::<std::collections::BTreeMap<_, _>>();
16412 assert!(v2_take_remote_selection(&files, &resurrected).is_err());
16413 }
16414
16415 #[cfg(target_os = "linux")]
16416 #[test]
16417 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
16418 use std::os::fd::AsRawFd as _;
16419
16420 let sandbox = tempfile::TempDir::new().unwrap();
16421 let parent = std::fs::File::open(sandbox.path()).unwrap();
16422 let stage = std::ffi::CString::new("stage").unwrap();
16423 let destination = std::ffi::CString::new("brain").unwrap();
16424
16425 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
16426 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
16427 install_stage_at(
16428 parent.as_raw_fd(),
16429 stage.as_c_str(),
16430 destination.as_c_str(),
16431 false,
16432 )
16433 .unwrap();
16434 assert!(!sandbox.path().join("stage").exists());
16435 assert_eq!(
16436 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
16437 b"created"
16438 );
16439
16440 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
16441 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
16442 install_stage_at(
16443 parent.as_raw_fd(),
16444 stage.as_c_str(),
16445 destination.as_c_str(),
16446 true,
16447 )
16448 .unwrap();
16449 assert_eq!(
16450 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
16451 b"replacement"
16452 );
16453 assert_eq!(
16454 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
16455 b"created",
16456 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
16457 );
16458 }
16459
16460 struct SignedRemoteFixture {
16461 card: String,
16462 feed: String,
16463 key: AgentSigningKey,
16464 identity: FeedIdentity,
16465 }
16466
16467 fn signed_remote_fixture() -> SignedRemoteFixture {
16468 let rng = ring::rand::SystemRandom::new();
16469 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16470 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16471 let (public_key, multikey) = public_identity_for(&pair);
16472 let identity = FeedIdentity {
16473 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16474 public_key_spki: public_key.clone(),
16475 previous: Vec::new(),
16476 rotations: Vec::new(),
16477 };
16478 let mut entry = FeedEntry {
16479 v: 1,
16480 seq: 1,
16481 ts: "2026-07-30T12:00:00.000Z".to_string(),
16482 brain: multikey.clone(),
16483 public_key: public_key.clone(),
16484 kind: "push".to_string(),
16485 op: "snapshot".to_string(),
16486 pack_sha256: "a".repeat(64),
16487 files: Vec::new(),
16488 removed: Vec::new(),
16489 prev_entry_hash: None,
16490 sig: String::new(),
16491 };
16492 let unsigned = UnsignedFeedEntry {
16493 v: entry.v,
16494 seq: entry.seq,
16495 ts: &entry.ts,
16496 brain: &entry.brain,
16497 public_key: &entry.public_key,
16498 kind: &entry.kind,
16499 op: &entry.op,
16500 pack_sha256: &entry.pack_sha256,
16501 files: &entry.files,
16502 removed: &entry.removed,
16503 prev_entry_hash: &entry.prev_entry_hash,
16504 };
16505 entry.sig =
16506 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16507 let mut exact = serde_json::to_vec(&entry).unwrap();
16508 exact.push(b'\n');
16509 let hash = content_sha256(&exact);
16510 let card = json!({
16511 "id": TEST_BRAIN_ID,
16512 "headSeq": 1,
16513 "feedHash": hash,
16514 "identity": identity.clone(),
16515 })
16516 .to_string();
16517 let feed = json!({
16518 "headSeq": 1,
16519 "feedHash": hash,
16520 "identity": identity.clone(),
16521 "entries": [{"hash": hash, "entry": entry}],
16522 "scopeLimited": false,
16523 })
16524 .to_string();
16525 SignedRemoteFixture {
16526 card,
16527 feed,
16528 key: AgentSigningKey {
16529 pkcs8: pkcs8.as_ref().to_vec(),
16530 multikey,
16531 public_key_spki: public_key,
16532 },
16533 identity,
16534 }
16535 }
16536
16537 #[test]
16538 fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
16539 let file = |path: &str, byte: char| FeedFile {
16540 path: path.to_string(),
16541 sha256: byte.to_string().repeat(64),
16542 bytes: 1,
16543 };
16544 let a0 = file("records/a.md", 'a');
16545 let a1 = file("records/a.md", 'b');
16546 let stable = file("records/stable.md", 'c');
16547 let added = file("records/added.md", 'd');
16548 let removed_file = file("records/removed.md", 'e');
16549 let previous = vec![a0, stable.clone(), removed_file.clone()];
16550 let resulting = vec![a1.clone(), stable.clone(), added.clone()];
16551 let removed = vec![removed_file.path.clone()];
16552
16553 assert_eq!(
16554 verify_v1_manifest_disclosure(
16555 "edit",
16556 &previous,
16557 &resulting,
16558 &[a1.clone(), added.clone()],
16559 &removed,
16560 ),
16561 Ok(())
16562 );
16563 assert_eq!(
16564 verify_v1_manifest_disclosure(
16565 "edit",
16566 &previous,
16567 &resulting,
16568 &[stable.clone(), added.clone(), a1.clone()],
16569 &removed,
16570 ),
16571 Ok(())
16572 );
16573 assert_eq!(
16574 verify_v1_manifest_disclosure(
16575 "edit",
16576 &previous,
16577 &resulting,
16578 std::slice::from_ref(&added),
16579 &removed,
16580 ),
16581 Err(V1DisclosureError::EditMissingChange)
16582 );
16583 assert_eq!(
16584 verify_v1_manifest_disclosure(
16585 "edit",
16586 &previous,
16587 &resulting,
16588 &[file("records/a.md", 'f'), added.clone()],
16589 &removed,
16590 ),
16591 Err(V1DisclosureError::EditFalseFile)
16592 );
16593 assert_eq!(
16594 verify_v1_manifest_disclosure(
16595 "edit",
16596 &previous,
16597 &resulting,
16598 &[a1.clone(), added.clone()],
16599 &[],
16600 ),
16601 Err(V1DisclosureError::RemovedMismatch)
16602 );
16603 assert_eq!(
16604 verify_v1_manifest_disclosure(
16605 "push",
16606 &previous,
16607 &resulting,
16608 &[added.clone(), stable, a1],
16609 &removed,
16610 ),
16611 Ok(())
16612 );
16613 assert_eq!(
16614 verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
16615 Err(V1DisclosureError::PushManifestMismatch)
16616 );
16617 }
16618
16619 #[test]
16620 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
16621 let fixture = signed_remote_fixture();
16622 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
16623 let item = feed["entries"][0].to_string();
16624 let oversized_page = format!(
16625 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
16626 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
16627 .collect::<Vec<_>>()
16628 .join(",")
16629 );
16630 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
16631
16632 let oversized_identity = format!(
16633 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
16634 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
16635 .collect::<Vec<_>>()
16636 .join(",")
16637 );
16638 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
16639
16640 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
16641 let oversized_entry = format!(
16642 "{{\"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\"}}",
16643 "a".repeat(64),
16644 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
16645 .collect::<Vec<_>>()
16646 .join(",")
16647 );
16648 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
16649 }
16650
16651 #[test]
16652 fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
16653 let id = "01arz3ndektsv4rrffq69g5fav";
16654 let digest = "a".repeat(64);
16655 assert_eq!(
16656 V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
16657 V2BulkConfirmation {
16658 id: id.to_string(),
16659 digest,
16660 }
16661 );
16662 for invalid in [
16663 "",
16664 "01arz3ndektsv4rrffq69g5fav",
16665 "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
16666 "01arz3ndektsv4rrffq69g5fav:ABCDEF",
16667 "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
16668 ] {
16669 assert!(matches!(
16670 V2BulkConfirmation::parse(invalid),
16671 Err(LinkError::InvalidPack { .. })
16672 ));
16673 }
16674 }
16675
16676 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
16677 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
16678 use std::net::TcpListener;
16679
16680 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16681 let url = format!("http://{}", listener.local_addr().unwrap());
16682 let handle = std::thread::spawn(move || {
16683 for (status, body) in responses {
16684 let (stream, _) = listener.accept().unwrap();
16685 let mut reader = BufReader::new(stream);
16686 let mut line = String::new();
16687 reader.read_line(&mut line).unwrap();
16688 let mut content_length = 0usize;
16689 loop {
16690 line.clear();
16691 reader.read_line(&mut line).unwrap();
16692 if line == "\r\n" || line == "\n" || line.is_empty() {
16693 break;
16694 }
16695 if let Some((name, value)) = line.split_once(':') {
16696 if name.eq_ignore_ascii_case("content-length") {
16697 content_length = value.trim().parse().unwrap();
16698 }
16699 }
16700 }
16701 let mut request_body = vec![0_u8; content_length];
16702 reader.read_exact(&mut request_body).unwrap();
16703 let response = format!(
16704 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
16705 body.len()
16706 );
16707 reader.get_mut().write_all(response.as_bytes()).unwrap();
16708 }
16709 });
16710 (url, handle)
16711 }
16712
16713 fn routed_json_hub(
16714 requests: usize,
16715 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
16716 ) -> (String, std::thread::JoinHandle<()>) {
16717 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
16718 use std::net::TcpListener;
16719
16720 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16721 let url = format!("http://{}", listener.local_addr().unwrap());
16722 let handle = std::thread::spawn(move || {
16723 for _ in 0..requests {
16724 let (stream, _) = listener.accept().unwrap();
16725 let mut reader = BufReader::new(stream);
16726 let mut line = String::new();
16727 reader.read_line(&mut line).unwrap();
16728 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
16729 let mut content_length = 0usize;
16730 loop {
16731 line.clear();
16732 reader.read_line(&mut line).unwrap();
16733 if line == "\r\n" || line == "\n" || line.is_empty() {
16734 break;
16735 }
16736 if let Some((name, value)) = line.split_once(':') {
16737 if name.eq_ignore_ascii_case("content-length") {
16738 content_length = value.trim().parse().unwrap();
16739 }
16740 }
16741 }
16742 let mut request_body = vec![0_u8; content_length];
16743 reader.read_exact(&mut request_body).unwrap();
16744 let (status, body) = respond(&path);
16745 let response = format!(
16746 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
16747 body.len()
16748 );
16749 reader.get_mut().write_all(response.as_bytes()).unwrap();
16750 }
16751 });
16752 (url, handle)
16753 }
16754
16755 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
16756 HubConfig {
16757 hub,
16758 key: Some("test-key".to_string()),
16759 agent_key: None,
16760 brain_key: None,
16761 state_dir,
16762 store_selected: false,
16763 }
16764 }
16765
16766 #[cfg(any(unix, windows))]
16767 #[test]
16768 fn an_expired_asset_capability_is_refreshed_without_losing_cached_progress() {
16769 use std::sync::{Arc, Mutex};
16770
16771 let bytes = b"immutable asset bytes".to_vec();
16772 let sha256 = content_sha256(&bytes);
16773 let commit_hash = "c".repeat(64);
16774 let base_url = Arc::new(Mutex::new(String::new()));
16775 let server_base = Arc::clone(&base_url);
16776 let object_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16777 let server_attempt = Arc::clone(&object_attempt);
16778 let response_bytes = bytes.clone();
16779 let response_sha = sha256.clone();
16780 let response_commit = commit_hash.clone();
16781 let (hub, server) = routed_json_hub(4, move |path| {
16782 if path.contains("/v2/assets/downloads") {
16783 let attempt = server_attempt.load(std::sync::atomic::Ordering::SeqCst) + 1;
16784 let url = format!("{}/asset/{attempt}", server_base.lock().unwrap());
16785 return (
16786 200,
16787 json!({
16788 "v": 2,
16789 "commit": response_commit,
16790 "downloads": [{
16791 "path": "assets/proof.bin",
16792 "sha256": response_sha,
16793 "bytes": response_bytes.len(),
16794 "url": url,
16795 "method": "GET"
16796 }]
16797 })
16798 .to_string(),
16799 );
16800 }
16801 let attempt = server_attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16802 if attempt == 0 {
16803 (403, "{}".to_string())
16804 } else {
16805 (200, String::from_utf8(response_bytes.clone()).unwrap())
16806 }
16807 });
16808 *base_url.lock().unwrap() = hub.clone();
16809
16810 let temp = tempfile::tempdir().unwrap();
16811 let cache = temp.path().join("cache");
16812 std::fs::create_dir(&cache).unwrap();
16813 let cfg = test_hub_config(hub, temp.path().to_path_buf());
16814 let pointer = V2PointerBody {
16815 v: 2,
16816 brain: TEST_BRAIN_ID.to_string(),
16817 seq: 1,
16818 commit_hash,
16819 feed_hash: "f".repeat(64),
16820 content_root: Some("a".repeat(64)),
16821 asset_root: Some("b".repeat(64)),
16822 materializer: "m".repeat(64),
16823 signer_epoch: 1,
16824 control_revision: "d".repeat(64),
16825 backup_preparation: "ready".to_string(),
16826 prior_pointer_hash: None,
16827 signed_at: "2026-08-23T00:00:00Z".to_string(),
16828 };
16829 let path = "assets/proof.bin".to_string();
16830 let asset = V2BaselineAsset {
16831 blob_sha256: sha256.clone(),
16832 bytes: bytes.len() as u64,
16833 media_type: "application/octet-stream".to_string(),
16834 wrappers: Vec::new(),
16835 required: true,
16836 disposition: "hosted".to_string(),
16837 leaf_hash: "e".repeat(64),
16838 };
16839
16840 let staged = stage_v2_asset_download_window(
16841 &cfg,
16842 TEST_BRAIN_ID,
16843 &pointer,
16844 &cache,
16845 &[(&path, &asset)],
16846 )
16847 .expect("a fresh authority-checked capability recovers an expired one");
16848 assert_eq!(staged.len(), 1);
16849 assert_eq!(staged[0].path, path);
16850 assert_eq!(std::fs::read(cache.join(sha256)).unwrap(), bytes);
16851 assert_eq!(object_attempt.load(std::sync::atomic::Ordering::SeqCst), 2);
16852 server.join().unwrap();
16853 }
16854
16855 #[cfg(any(unix, windows))]
16856 #[test]
16857 fn asset_capability_windows_cannot_exceed_the_worker_bound() {
16858 let temp = tempfile::tempdir().unwrap();
16859 let cfg = test_hub_config("http://127.0.0.1:1".to_string(), temp.path().to_path_buf());
16860 let pointer = V2PointerBody {
16861 v: 2,
16862 brain: TEST_BRAIN_ID.to_string(),
16863 seq: 1,
16864 commit_hash: "c".repeat(64),
16865 feed_hash: "f".repeat(64),
16866 content_root: Some("a".repeat(64)),
16867 asset_root: Some("b".repeat(64)),
16868 materializer: "m".repeat(64),
16869 signer_epoch: 1,
16870 control_revision: "d".repeat(64),
16871 backup_preparation: "ready".to_string(),
16872 prior_pointer_hash: None,
16873 signed_at: "2026-08-23T00:00:00Z".to_string(),
16874 };
16875 let paths = (0..=V2_DOWNLOAD_CAPABILITY_FILES)
16876 .map(|index| format!("assets/{index}.bin"))
16877 .collect::<Vec<_>>();
16878 let assets = paths
16879 .iter()
16880 .map(|_| V2BaselineAsset {
16881 blob_sha256: "a".repeat(64),
16882 bytes: 1,
16883 media_type: "application/octet-stream".to_string(),
16884 wrappers: Vec::new(),
16885 required: true,
16886 disposition: "hosted".to_string(),
16887 leaf_hash: "b".repeat(64),
16888 })
16889 .collect::<Vec<_>>();
16890 let pending = paths.iter().zip(&assets).collect::<Vec<_>>();
16891
16892 let error =
16893 stage_v2_asset_download_window(&cfg, TEST_BRAIN_ID, &pointer, temp.path(), &pending)
16894 .expect_err("an oversized window must fail before any network request");
16895 assert!(matches!(error, LinkError::InvalidFeed { .. }));
16896 }
16897
16898 #[test]
16899 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
16900 use ring::signature::KeyPair as _;
16901
16902 let rng = ring::rand::SystemRandom::new();
16903 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16904 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16905 let (spki, multikey) = public_identity_for(&pair);
16906 let key = AgentSigningKey {
16907 pkcs8: pkcs8.as_ref().to_vec(),
16908 multikey,
16909 public_key_spki: spki,
16910 };
16911 let header = linkmd_sig_header(
16912 &key,
16913 "https://hub-a.example",
16914 "post",
16915 "/api/hub/brains/brain/push?mode=exact",
16916 Some("{\"ok\":true}"),
16917 )
16918 .unwrap();
16919 assert!(header.starts_with("LinkMD-Sig v2,"));
16920 let ts = header
16921 .split(",ts=")
16922 .nth(1)
16923 .unwrap()
16924 .split(',')
16925 .next()
16926 .unwrap();
16927 let signature = URL_SAFE_NO_PAD
16928 .decode(header.rsplit(",sig=").next().unwrap())
16929 .unwrap();
16930 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
16931 let accepted = format!(
16932 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
16933 );
16934 let replayed = format!(
16935 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
16936 );
16937 let public = pair.public_key().as_ref();
16938 assert!(UnparsedPublicKey::new(&ED25519, public)
16939 .verify(accepted.as_bytes(), &signature)
16940 .is_ok());
16941 assert!(
16942 UnparsedPublicKey::new(&ED25519, public)
16943 .verify(replayed.as_bytes(), &signature)
16944 .is_err(),
16945 "a proof captured at hub A must not authenticate at hub B"
16946 );
16947 }
16948
16949 #[test]
16950 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
16951 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16952 let card = json!({
16953 "id": other,
16954 "headSeq": 0,
16955 "identity": signed_remote_fixture().identity,
16956 })
16957 .to_string();
16958 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
16959 let state = tempfile::tempdir().unwrap();
16960 let cfg = test_hub_config(hub, state.path().to_path_buf());
16961 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16962 assert!(
16963 error.contains("differs from the explicitly requested"),
16964 "{error}"
16965 );
16966 server.join().unwrap();
16967 }
16968
16969 #[test]
16970 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
16971 let first = signed_remote_fixture().identity;
16972 let second = signed_remote_fixture().identity;
16973 let card = |identity: FeedIdentity| {
16974 json!({
16975 "id": TEST_BRAIN_ID,
16976 "headSeq": 0,
16977 "identity": identity,
16978 })
16979 .to_string()
16980 };
16981 let (hub, server) = scripted_json_hub(vec![
16982 (404, "{}".to_string()),
16983 (200, card(first)),
16984 (404, "{}".to_string()),
16985 (200, card(second)),
16986 ]);
16987 let state = tempfile::tempdir().unwrap();
16988 let cfg = test_hub_config(hub, state.path().to_path_buf());
16989 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16990 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16991 assert!(
16992 error.contains("pinned anchor") || error.contains("forked away"),
16993 "{error}"
16994 );
16995 server.join().unwrap();
16996 }
16997
16998 #[test]
16999 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
17000 let old = signed_remote_fixture();
17001 let new = signed_remote_fixture();
17002 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
17003 let unsigned = serde_json::to_string(&UnsignedRotation {
17004 v: 1,
17005 op: "rotate",
17006 brain: &old.key.multikey,
17007 public_key: &old.key.public_key_spki,
17008 new_brain: &new.key.multikey,
17009 new_public_key: &new.key.public_key_spki,
17010 prior_head_seq: 1,
17011 prior_feed_hash: Some(&"a".repeat(64)),
17012 ts: "2026-07-30T12:00:00.000Z".to_string(),
17013 })
17014 .unwrap();
17015 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
17016 let rotation = format!(
17017 "{},\"sig\":\"{}\"}}",
17018 &unsigned[..unsigned.len() - 1],
17019 signature
17020 );
17021 let identity = FeedIdentity {
17022 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
17023 public_key_spki: new.key.public_key_spki,
17024 previous: vec![PreviousIdentity {
17025 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
17026 public_key_spki: old.key.public_key_spki,
17027 }],
17028 rotations: vec![rotation],
17029 };
17030 let card = json!({
17031 "id": TEST_BRAIN_ID,
17032 "headSeq": 0,
17033 "feedHash": null,
17034 "identity": identity,
17035 })
17036 .to_string();
17037 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
17038 let state = tempfile::tempdir().unwrap();
17039 let cfg = test_hub_config(hub, state.path().to_path_buf());
17040 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
17041 assert!(
17042 error.contains("rotation claims a feed boundary beyond the advertised head"),
17043 "{error}"
17044 );
17045 assert!(
17046 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
17047 "an inconsistent empty-head identity must not become the TOFU checkpoint"
17048 );
17049 server.join().unwrap();
17050 }
17051
17052 #[test]
17053 fn trust_checkpoint_rejects_a_later_fork() {
17054 let fixture = signed_remote_fixture();
17055 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
17056 fork["feedHash"] = Value::String("b".repeat(64));
17057 let (hub, server) = scripted_json_hub(vec![
17058 (404, "{}".to_string()),
17059 (200, fixture.card),
17060 (200, fixture.feed),
17061 (404, "{}".to_string()),
17062 (200, fork.to_string()),
17063 ]);
17064 let state = tempfile::tempdir().unwrap();
17065 let cfg = test_hub_config(hub, state.path().to_path_buf());
17066 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
17067 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
17068 server.join().unwrap();
17069 }
17070
17071 #[test]
17072 fn alias_and_canonical_id_share_one_identity_checkpoint() {
17073 let trusted = signed_remote_fixture();
17074 let attacker = signed_remote_fixture();
17075 let (hub, server) = scripted_json_hub(vec![
17076 (404, "{}".to_string()),
17077 (200, trusted.card),
17078 (200, trusted.feed),
17079 (404, "{}".to_string()),
17080 (200, attacker.card),
17081 ]);
17082 let state = tempfile::tempdir().unwrap();
17083 let cfg = test_hub_config(hub, state.path().to_path_buf());
17084 assert!(head(&cfg, "trusted-slug").unwrap().verified);
17085 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
17086 assert!(
17087 error.contains("equivocation")
17088 || error.contains("pinned")
17089 || error.contains("identity"),
17090 "{error}"
17091 );
17092 server.join().unwrap();
17093 }
17094
17095 #[test]
17096 fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
17097 let state = tempfile::tempdir().unwrap();
17098 let cfg = test_hub_config(
17099 "https://hub.example".to_string(),
17100 state.path().to_path_buf(),
17101 );
17102 let directory = open_trust_dir(&cfg).unwrap();
17103 let old = TEST_BRAIN_ID;
17104 let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
17105 save_alias_in(
17106 &cfg,
17107 &directory,
17108 &AliasBinding {
17109 v: 1,
17110 origin: normalized_origin(&cfg.hub).unwrap(),
17111 requested: "company-brain".to_string(),
17112 brain: old.to_string(),
17113 home: Some("company-brain".to_string()),
17114 },
17115 )
17116 .unwrap();
17117
17118 let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
17119 assert!(matches!(
17120 error,
17121 LinkError::AliasRebindRequired {
17122 alias,
17123 from,
17124 to
17125 } if alias == "company-brain" && from == old && to == new
17126 ));
17127 let unchanged = load_alias_in(&cfg, &directory, "company-brain")
17128 .unwrap()
17129 .unwrap();
17130 assert_eq!(unchanged.brain, old);
17131 assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
17132 }
17133
17134 #[test]
17135 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
17136 let alpha = signed_remote_fixture();
17137 let beta = signed_remote_fixture();
17138 let alpha_card = alpha.card.clone();
17139 let alpha_feed = alpha.feed.clone();
17140 let beta_card = beta.card.clone();
17141 let beta_feed = beta.feed.clone();
17142 let (hub, server) = routed_json_hub(5, move |path| {
17143 if path.ends_with("/v2/head") {
17144 (404, "{}".to_string())
17145 } else if path.contains("/alpha/feed?") {
17146 (200, alpha_feed.clone())
17147 } else if path.contains("/beta/feed?") {
17148 (200, beta_feed.clone())
17149 } else if path.ends_with("/alpha") {
17150 (200, alpha_card.clone())
17151 } else if path.ends_with("/beta") {
17152 (200, beta_card.clone())
17153 } else {
17154 (500, r#"{"error":"unexpected path"}"#.to_string())
17155 }
17156 });
17157 let state = tempfile::tempdir().unwrap();
17158 let cfg = test_hub_config(hub, state.path().to_path_buf());
17159 let alpha_cfg = cfg.clone();
17160 let beta_cfg = cfg;
17161 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
17162 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
17163 let results = [first.join().unwrap(), second.join().unwrap()];
17164 assert_eq!(
17165 results.iter().filter(|result| result.is_ok()).count(),
17166 1,
17167 "only one alias identity may establish canonical TOFU: {results:?}"
17168 );
17169 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
17170 server.join().unwrap();
17171 }
17172
17173 #[cfg(unix)]
17174 #[test]
17175 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
17176 use std::os::unix::fs::symlink;
17177
17178 let fixture = signed_remote_fixture();
17179 let card = json!({
17180 "id": TEST_BRAIN_ID,
17181 "headSeq": 0,
17182 "feedHash": Value::Null,
17183 "identity": fixture.identity,
17184 })
17185 .to_string();
17186 let work = tempfile::tempdir().unwrap();
17187 let outside = tempfile::tempdir().unwrap();
17188 let state = work.path().join("state");
17189 let moved = work.path().join("state-held");
17190 let swap_state = state.clone();
17191 let swap_moved = moved.clone();
17192 let outside_path = outside.path().to_path_buf();
17193 let (hub, server) = routed_json_hub(1, move |_| {
17194 std::fs::rename(&swap_state, &swap_moved).unwrap();
17196 symlink(&outside_path, &swap_state).unwrap();
17197 (200, card.clone())
17198 });
17199 let cfg = test_hub_config(hub, state);
17200
17201 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
17202 assert_eq!(verified.head.seq, 0);
17203 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
17204 assert!(std::fs::read_dir(moved.join("trust"))
17205 .unwrap()
17206 .flatten()
17207 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
17208 server.join().unwrap();
17209 }
17210
17211 #[test]
17212 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
17213 let remote = signed_remote_fixture();
17214 let unrelated = signed_remote_fixture().key;
17215 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
17216 let state = tempfile::tempdir().unwrap();
17217 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
17218 cfg.brain_key = Some(unrelated);
17219 let error = sync_push(
17220 &cfg,
17221 TEST_BRAIN_ID,
17222 &[("DB.md".to_string(), "signed local content".to_string())],
17223 )
17224 .unwrap_err()
17225 .to_string();
17226 assert!(
17227 error.contains("not the verified current brain identity"),
17228 "{error}"
17229 );
17230 server.join().unwrap();
17231 }
17232
17233 #[test]
17234 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
17235 let remote = signed_remote_fixture();
17236 let new = signed_remote_fixture().key;
17237 let state = tempfile::tempdir().unwrap();
17238 let new_file = state.path().join("new.key");
17239 std::fs::write(
17240 &new_file,
17241 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
17242 )
17243 .unwrap();
17244 #[cfg(unix)]
17245 {
17246 use std::os::unix::fs::PermissionsExt as _;
17247 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
17248 }
17249 let forged = json!({
17250 "brain": TEST_BRAIN_ID,
17251 "identity": {
17252 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
17253 "publicKeySpki": new.public_key_spki,
17254 }
17255 })
17256 .to_string();
17257 let (hub, server) = scripted_json_hub(vec![
17258 (404, "{}".to_string()),
17259 (200, remote.card.clone()),
17260 (200, remote.feed.clone()),
17261 (200, forged),
17262 (200, remote.card),
17263 (200, remote.feed),
17264 ]);
17265 let cfg = test_hub_config(hub, state.path().to_path_buf());
17266 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
17267 .unwrap_err()
17268 .to_string();
17269 assert!(
17270 error.contains("without committing the verified new identity"),
17271 "{error}"
17272 );
17273 server.join().unwrap();
17274 }
17275
17276 #[test]
17277 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
17278 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
17279 let raw = format!(
17280 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
17281 );
17282 let pack = build_store_pack(&[
17283 (
17284 "DB.md".to_string(),
17285 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
17286 ),
17287 ("records/clients/truth.md".to_string(), raw.clone()),
17288 ])
17289 .unwrap();
17290 let by_id = resolve_from_verified_pack(
17291 "01j5qc3v9k4ym8rwbn2tqe6f7d",
17292 &AddressTarget::Id(record_id.to_string()),
17293 pack.clone(),
17294 )
17295 .unwrap();
17296 assert_eq!(by_id["document"]["summary"], "Signed truth");
17297 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
17298 assert_eq!(
17299 by_id["document"]["contentSha"],
17300 content_sha256(raw.as_bytes())
17301 );
17302
17303 let by_path = resolve_from_verified_pack(
17304 "01j5qc3v9k4ym8rwbn2tqe6f7d",
17305 &AddressTarget::Path("records/clients/truth.md".to_string()),
17306 pack,
17307 )
17308 .unwrap();
17309 assert_eq!(by_path["document"]["id"], record_id);
17310 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
17311
17312 let wrong_id = resolve_from_verified_record_bytes(
17313 TEST_BRAIN_ID,
17314 &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
17315 "records/clients/truth.md".to_string(),
17316 raw.as_bytes().to_vec(),
17317 )
17318 .unwrap_err()
17319 .to_string();
17320 assert!(wrong_id.contains("id differs"), "{wrong_id}");
17321
17322 let wrong_path = resolve_from_verified_record_bytes(
17323 TEST_BRAIN_ID,
17324 &AddressTarget::Path("records/clients/other.md".to_string()),
17325 "records/clients/truth.md".to_string(),
17326 raw.into_bytes(),
17327 )
17328 .unwrap_err()
17329 .to_string();
17330 assert!(wrong_path.contains("path differs"), "{wrong_path}");
17331 }
17332
17333 #[test]
17334 fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
17335 let path = "records/clients/truth.md";
17336 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
17337 let raw = format!(
17338 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
17339 );
17340 let sha256 = content_sha256(raw.as_bytes());
17341 let mut nonce = 0_u128;
17342 let tree = crate::linkmd_v2::build_content_tree(
17343 &[crate::linkmd_v2::ContentFile {
17344 path: path.to_string(),
17345 blob_hash: sha256.clone(),
17346 bytes: raw.len() as u64,
17347 }],
17348 None,
17349 &mut || {
17350 nonce += 1;
17351 format!("{nonce:032x}")
17352 },
17353 )
17354 .unwrap();
17355 let root = tree.root.clone().unwrap();
17356 let mut directory_root = root.clone();
17357 let mut proof = Vec::new();
17358 for component in path.split('/') {
17359 let inclusion =
17360 crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
17361 let child = match &inclusion {
17362 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
17363 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
17364 panic!("fixture path must have an inclusion proof")
17365 }
17366 };
17367 proof.push(json!({
17368 "directory_root": directory_root,
17369 "component": component,
17370 "proof": inclusion,
17371 }));
17372 directory_root = child;
17373 }
17374 let commit_hash = "c".repeat(64);
17375 let pointer = V2PointerBody {
17376 v: 2,
17377 brain: TEST_BRAIN_ID.to_string(),
17378 seq: 1,
17379 commit_hash: commit_hash.clone(),
17380 feed_hash: "f".repeat(64),
17381 content_root: Some(root.clone()),
17382 asset_root: None,
17383 materializer: "dbmd-projection-v1".to_string(),
17384 signer_epoch: 1,
17385 control_revision: "d".repeat(64),
17386 backup_preparation: "e".repeat(64),
17387 prior_pointer_hash: None,
17388 signed_at: "2026-08-21T12:00:00.000Z".to_string(),
17389 };
17390 let manifest = json!({
17391 "v": 2,
17392 "commit": commit_hash,
17393 "content_root": root,
17394 "files": [{
17395 "path": path,
17396 "sha256": sha256,
17397 "bytes": raw.len(),
17398 "proof": proof,
17399 }],
17400 "next_cursor": Value::Null,
17401 })
17402 .to_string();
17403
17404 let path_manifest = manifest.clone();
17405 let (hub, server) = routed_json_hub(1, move |request| {
17406 assert_eq!(
17407 request,
17408 format!(
17409 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
17410 "c".repeat(64)
17411 )
17412 );
17413 (200, path_manifest.clone())
17414 });
17415 let state = tempfile::tempdir().unwrap();
17416 let cfg = test_hub_config(hub, state.path().to_path_buf());
17417 let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
17418 .unwrap()
17419 .unwrap();
17420 assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
17421 assert!(by_path.proof.is_some());
17422 server.join().unwrap();
17423
17424 let (hub, server) = routed_json_hub(1, move |request| {
17425 assert_eq!(
17426 request,
17427 format!(
17428 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
17429 "c".repeat(64)
17430 )
17431 );
17432 (404, r#"{"error":"File not found"}"#.to_string())
17433 });
17434 let state = tempfile::tempdir().unwrap();
17435 let cfg = test_hub_config(hub, state.path().to_path_buf());
17436 assert!(
17437 v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
17438 .unwrap()
17439 .is_none()
17440 );
17441 server.join().unwrap();
17442
17443 let id_manifest = manifest;
17444 let (hub, server) = routed_json_hub(1, move |request| {
17445 assert_eq!(
17446 request,
17447 format!(
17448 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
17449 "c".repeat(64)
17450 )
17451 );
17452 (200, id_manifest.clone())
17453 });
17454 let state = tempfile::tempdir().unwrap();
17455 let cfg = test_hub_config(hub, state.path().to_path_buf());
17456 let (located_path, by_id) =
17457 v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
17458 assert_eq!(located_path, path);
17459 assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
17460 server.join().unwrap();
17461 }
17462
17463 #[test]
17464 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
17465 let unsorted = vec![
17466 ("records/a.md".to_string(), "alpha\n".to_string()),
17467 ("DB.md".to_string(), "# db\n".to_string()),
17468 ];
17469 let sorted = vec![
17470 ("DB.md".to_string(), "# db\n".to_string()),
17471 ("records/a.md".to_string(), "alpha\n".to_string()),
17472 ];
17473 let pack = build_store_pack(&unsorted).unwrap();
17474
17475 assert_eq!(pack.len(), 219);
17480 assert_eq!(
17481 content_sha256(&pack),
17482 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
17483 );
17484 assert_eq!(pack, build_store_pack(&sorted).unwrap());
17485 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
17486 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
17487 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
17488
17489 assert_eq!(
17490 parse_store_pack(pack).unwrap(),
17491 vec![
17492 ("DB.md".to_string(), b"# db\n".to_vec()),
17493 ("records/a.md".to_string(), b"alpha\n".to_vec()),
17494 ]
17495 );
17496 }
17497
17498 #[test]
17499 fn canonical_store_pack_validates_every_path_before_writing() {
17500 let duplicate = vec![
17501 ("DB.md".to_string(), "first".to_string()),
17502 ("DB.md".to_string(), "second".to_string()),
17503 ];
17504 assert!(build_store_pack(&duplicate)
17505 .unwrap_err()
17506 .to_string()
17507 .contains("duplicate path"));
17508 assert!(matches!(
17509 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
17510 Err(LinkError::UnsafePath { .. })
17511 ));
17512 }
17513
17514 #[test]
17515 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
17516 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
17517 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
17541 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
17542 .unwrap_err()
17543 .to_string();
17544 assert!(error.contains("invalid file count"), "{error}");
17545 }
17546
17547 #[test]
17548 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
17549 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
17550 let mut bytes = vec![0_u8];
17551 let zip64_offset = bytes.len() as u64;
17552 bytes.extend_from_slice(b"PK\x06\x06");
17553 bytes.extend_from_slice(&44_u64.to_le_bytes());
17554 bytes.extend_from_slice(&[0_u8; 12]);
17555 bytes.extend_from_slice(&COUNT.to_le_bytes());
17556 bytes.extend_from_slice(&COUNT.to_le_bytes());
17557 bytes.extend_from_slice(&1_u64.to_le_bytes());
17558 bytes.extend_from_slice(&0_u64.to_le_bytes());
17559 bytes.extend_from_slice(b"PK\x06\x07");
17560 bytes.extend_from_slice(&0_u32.to_le_bytes());
17561 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
17562 bytes.extend_from_slice(&1_u32.to_le_bytes());
17563 bytes.extend_from_slice(b"PK\x05\x06");
17564 bytes.extend_from_slice(&0_u16.to_le_bytes());
17565 bytes.extend_from_slice(&0_u16.to_le_bytes());
17566 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17567 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17568 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17569 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17570 bytes.extend_from_slice(&0_u16.to_le_bytes());
17571 let fake_eocd = bytes.len() as u32;
17575 bytes.extend_from_slice(b"PK\x05\x06");
17576 bytes.extend_from_slice(&0_u16.to_le_bytes());
17577 bytes.extend_from_slice(&0_u16.to_le_bytes());
17578 bytes.extend_from_slice(&1_u16.to_le_bytes());
17579 bytes.extend_from_slice(&1_u16.to_le_bytes());
17580 bytes.extend_from_slice(&0_u32.to_le_bytes());
17581 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
17582 bytes.extend_from_slice(&0_u16.to_le_bytes());
17583
17584 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
17585 .unwrap_err()
17586 .to_string();
17587 assert!(error.contains("central directory"), "{error}");
17588 }
17589
17590 #[test]
17591 fn strict_http_status_handling_rejects_redirects_without_panicking() {
17592 let error = ensure_ok(
17593 HubResponse {
17594 status: 302,
17595 body: Some(json!({"redirect": "/elsewhere"})),
17596 },
17597 "mutation",
17598 )
17599 .unwrap_err();
17600 assert!(matches!(error, LinkError::Http { status: 302, .. }));
17601
17602 let error = ensure_raw_ok(
17603 RawHubResponse {
17604 status: 302,
17605 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
17606 },
17607 "feed",
17608 )
17609 .unwrap_err();
17610 assert!(matches!(error, LinkError::Http { status: 302, .. }));
17611 }
17612
17613 #[cfg(unix)]
17614 #[test]
17615 fn collect_push_files_refuses_external_symlink_and_nested_store() {
17616 use std::os::unix::fs::symlink;
17617
17618 let root = tempfile::tempdir().unwrap();
17619 std::fs::write(
17620 root.path().join("DB.md"),
17621 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
17622 )
17623 .unwrap();
17624 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
17625
17626 let external = tempfile::tempdir().unwrap();
17627 let secret = external.path().join("secret.md");
17628 std::fs::write(&secret, "TOP SECRET").unwrap();
17629 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
17630
17631 let store = Store::open_strict(root.path()).unwrap();
17632 let err = collect_push_files(&store).unwrap_err().to_string();
17633 assert!(err.contains("cannot push"), "{err}");
17634 assert!(
17635 !err.contains("TOP SECRET"),
17636 "external bytes must never leak"
17637 );
17638
17639 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
17640 let nested = root.path().join("records/nested");
17641 std::fs::create_dir_all(&nested).unwrap();
17642 std::fs::write(
17643 nested.join("DB.md"),
17644 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
17645 )
17646 .unwrap();
17647 let err = collect_push_files(&store).unwrap_err().to_string();
17648 assert!(err.contains("nested db.md store"), "{err}");
17649 }
17650
17651 #[test]
17652 fn collect_push_files_carries_curator_history_but_not_derived_catalogs() {
17653 let root = tempfile::tempdir().unwrap();
17654 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
17655 std::fs::create_dir_all(root.path().join("log")).unwrap();
17656 std::fs::write(
17657 root.path().join("DB.md"),
17658 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
17659 )
17660 .unwrap();
17661 std::fs::write(root.path().join("index.md"), "derived root catalog\n").unwrap();
17662 std::fs::write(
17663 root.path().join("records/notes/index.md"),
17664 "derived type catalog\n",
17665 )
17666 .unwrap();
17667 std::fs::write(
17668 root.path().join("records/notes/owned.md"),
17669 "---\ntype: note\nsummary: owned\ncreated: 2026-08-26T00:00:00Z\nupdated: 2026-08-26T00:00:00Z\n---\n",
17670 )
17671 .unwrap();
17672 std::fs::write(
17673 root.path().join("log.md"),
17674 "---\ntype: log\n---\n\n# Curator log\n",
17675 )
17676 .unwrap();
17677 std::fs::write(
17678 root.path().join("log/2026-07.md"),
17679 "---\ntype: log\n---\n\n# Curator log — 2026-07\n",
17680 )
17681 .unwrap();
17682 std::fs::write(root.path().join("log/README.txt"), "not a log archive\n").unwrap();
17683
17684 let store = Store::open_strict(root.path()).unwrap();
17685 let paths: Vec<String> = collect_push_files(&store)
17686 .unwrap()
17687 .into_iter()
17688 .map(|(path, _)| path)
17689 .collect();
17690
17691 assert!(paths.contains(&"DB.md".to_string()));
17692 assert!(paths.contains(&"records/notes/owned.md".to_string()));
17693 assert!(paths.contains(&"log.md".to_string()));
17694 assert!(paths.contains(&"log/2026-07.md".to_string()));
17695 assert!(!paths.contains(&"index.md".to_string()));
17696 assert!(!paths.contains(&"records/notes/index.md".to_string()));
17697 assert!(!paths.contains(&"log/README.txt".to_string()));
17698 }
17699
17700 #[cfg(unix)]
17701 #[test]
17702 fn remote_push_uses_opened_root_after_path_replacement() {
17703 use std::os::unix::fs::symlink;
17704
17705 let sandbox = tempfile::tempdir().unwrap();
17706 let root = sandbox.path().join("store");
17707 std::fs::create_dir_all(root.join("records/notes")).unwrap();
17708 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17709 std::fs::write(
17710 root.join("records/notes/owned.md"),
17711 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
17712 )
17713 .unwrap();
17714 let store = Store::open_strict(&root).unwrap();
17715 let detached = sandbox.path().join("detached");
17716 std::fs::rename(&root, &detached).unwrap();
17717
17718 let replacement = sandbox.path().join("replacement");
17719 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
17720 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17721 std::fs::write(
17722 replacement.join("records/notes/secret.md"),
17723 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
17724 )
17725 .unwrap();
17726 symlink(&replacement, &root).unwrap();
17727
17728 let files = collect_push_files(&store).unwrap();
17729 let wire_text = files
17730 .iter()
17731 .map(|(path, content)| format!("{path}\n{content}"))
17732 .collect::<Vec<_>>()
17733 .join("\n");
17734 assert!(wire_text.contains("owned upload"));
17735 assert!(!wire_text.contains("replacement sentinel"));
17736 assert!(!wire_text.contains("records/notes/secret.md"));
17737
17738 let remote = signed_remote_fixture();
17739 let (hub, server) = scripted_json_hub(vec![
17740 (200, remote.card),
17741 (200, remote.feed),
17742 (200, json!({"ok": true}).to_string()),
17743 ]);
17744 let state = tempfile::tempdir().unwrap();
17745 let cfg = test_hub_config(hub, state.path().to_path_buf());
17746 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
17747 assert_eq!(pushed, json!({"ok": true}));
17748 server.join().unwrap();
17749 }
17750
17751 #[test]
17752 fn signed_feed_item_verifies_identity_hash_and_signature() {
17753 use ring::rand::SystemRandom;
17754 use ring::signature::{Ed25519KeyPair, KeyPair};
17755
17756 const PREFIX: &[u8] = &[
17757 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
17758 ];
17759 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
17760 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17761 let mut spki = PREFIX.to_vec();
17762 spki.extend_from_slice(pair.public_key().as_ref());
17763 let public_key = URL_SAFE_NO_PAD.encode(&spki);
17764 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
17765 let mut entry = FeedEntry {
17766 v: 1,
17767 seq: 1,
17768 ts: "2026-07-14T00:00:00.000Z".to_string(),
17769 brain: format!("ed25519:{fingerprint}"),
17770 public_key: public_key.clone(),
17771 kind: "push".to_string(),
17772 op: "snapshot".to_string(),
17773 pack_sha256: "a".repeat(64),
17774 files: vec![FeedFile {
17775 path: "DB.md".to_string(),
17776 sha256: "b".repeat(64),
17777 bytes: 3,
17778 }],
17779 removed: vec![],
17780 prev_entry_hash: None,
17781 sig: String::new(),
17782 };
17783 let unsigned = UnsignedFeedEntry {
17784 v: entry.v,
17785 seq: entry.seq,
17786 ts: &entry.ts,
17787 brain: &entry.brain,
17788 public_key: &entry.public_key,
17789 kind: &entry.kind,
17790 op: &entry.op,
17791 pack_sha256: &entry.pack_sha256,
17792 files: &entry.files,
17793 removed: &entry.removed,
17794 prev_entry_hash: &entry.prev_entry_hash,
17795 };
17796 entry.sig =
17797 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
17798 let mut exact = serde_json::to_vec(&entry).unwrap();
17799 exact.push(b'\n');
17800 let item = FeedItem {
17801 hash: format!("{:x}", Sha256::digest(&exact)),
17802 entry,
17803 };
17804 let identity = FeedIdentity {
17805 fingerprint,
17806 public_key_spki: public_key,
17807 previous: Vec::new(),
17808 rotations: Vec::new(),
17809 };
17810 assert!(verify_feed_item(&item, &identity).is_ok());
17811 let mut tampered = item;
17812 tampered.entry.pack_sha256 = "c".repeat(64);
17813 assert!(verify_feed_item(&tampered, &identity).is_err());
17814 }
17815
17816 #[test]
17817 fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
17818 let rng = ring::rand::SystemRandom::new();
17819 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17820 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17821 let (spki, multikey) = public_identity_for(&pair);
17822 let identity = V2HeadIdentity {
17823 custody: "self".to_string(),
17824 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
17825 public_key_spki: spki.clone(),
17826 previous: Vec::new(),
17827 rotations: Vec::new(),
17828 };
17829 let unsigned = json!({
17830 "actor_ref": "a".repeat(64),
17831 "asset_root": Value::Null,
17832 "brain": multikey,
17833 "changes_sha256": "b".repeat(64),
17834 "control_revision": "c".repeat(64),
17835 "materializer": "dbmd-projection-v1",
17836 "op": "changeset",
17837 "parent_asset_root": Value::Null,
17838 "parent_commit": Value::Null,
17839 "parent_root": Value::Null,
17840 "prev_entry_hash": Value::Null,
17841 "public_key": spki,
17842 "seq": 1,
17843 "signer_epoch": 1,
17844 "state_root": "d".repeat(64),
17845 "ts": "2026-08-19T12:00:00.000Z",
17846 "v": 2,
17847 "v1_bridge": {
17848 "feed_hash": "e".repeat(64),
17849 "head_seq": 7,
17850 "pack_sha256": "f".repeat(64),
17851 },
17852 });
17853 let sign_value = |value: Value| {
17854 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
17855 let mut object = value.as_object().unwrap().clone();
17856 object.insert(
17857 "sig".to_string(),
17858 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17859 );
17860 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
17861 };
17862 assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
17863
17864 let mut extra = unsigned.clone();
17865 extra
17866 .as_object_mut()
17867 .unwrap()
17868 .insert("future".to_string(), Value::Bool(true));
17869 assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
17870
17871 let mut missing = unsigned.clone();
17872 missing.as_object_mut().unwrap().remove("v1_bridge");
17873 assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
17874
17875 let mut invalid_bridge = unsigned;
17876 invalid_bridge.as_object_mut().unwrap().insert(
17877 "v1_bridge".to_string(),
17878 json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
17879 );
17880 assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
17881 }
17882
17883 #[test]
17884 fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
17885 let vector: Value = serde_json::from_str(include_str!(
17886 "../tests/vectors/linkmd-v2-commit-bridge.json"
17887 ))
17888 .unwrap();
17889 let identity_value = vector.get("identity").unwrap();
17890 let identity = V2HeadIdentity {
17891 custody: "self".to_string(),
17892 fingerprint: identity_value
17893 .get("fingerprint")
17894 .and_then(Value::as_str)
17895 .unwrap()
17896 .to_string(),
17897 public_key_spki: identity_value
17898 .get("public_key_spki")
17899 .and_then(Value::as_str)
17900 .unwrap()
17901 .to_string(),
17902 previous: Vec::new(),
17903 rotations: Vec::new(),
17904 };
17905 let private = URL_SAFE_NO_PAD
17906 .decode(
17907 identity_value
17908 .get("private_key_pkcs8")
17909 .and_then(Value::as_str)
17910 .unwrap(),
17911 )
17912 .unwrap();
17913 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
17914 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
17915 .unwrap();
17916 let base = vector.get("body").unwrap().as_object().unwrap();
17917
17918 for item in vector.get("valid").unwrap().as_array().unwrap() {
17919 let mut body = base.clone();
17920 body.insert(
17921 "v1_bridge".to_string(),
17922 item.get("v1_bridge").unwrap().clone(),
17923 );
17924 body.insert(
17925 "sig".to_string(),
17926 item.get("signature_base64url").unwrap().clone(),
17927 );
17928 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
17929 assert!(verified_v2_commit_object(&signed, &identity).is_ok());
17930 assert_eq!(
17931 crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
17932 item.get("commit_hash").and_then(Value::as_str).unwrap()
17933 );
17934 assert_eq!(
17935 format!("{:x}", Sha256::digest(&signed)),
17936 item.get("feed_hash").and_then(Value::as_str).unwrap()
17937 );
17938 }
17939
17940 for item in vector.get("invalid").unwrap().as_array().unwrap() {
17941 let mut body = base.clone();
17942 if let Some(remove) = item.get("remove").and_then(Value::as_array) {
17943 for field in remove {
17944 body.remove(field.as_str().unwrap());
17945 }
17946 }
17947 if let Some(set) = item.get("set").and_then(Value::as_object) {
17948 for (field, value) in set {
17949 body.insert(field.clone(), value.clone());
17950 }
17951 }
17952 let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
17953 body.insert(
17954 "sig".to_string(),
17955 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17956 );
17957 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
17958 assert!(
17959 verified_v2_commit_object(&signed, &identity).is_err(),
17960 "accepted invalid shared vector {}",
17961 item.get("reason").and_then(Value::as_str).unwrap()
17962 );
17963 }
17964 }
17965
17966 #[test]
17967 fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
17968 let vector: Value = serde_json::from_str(include_str!(
17969 "../tests/vectors/linkmd-v2-changeset-withheld.json"
17970 ))
17971 .unwrap();
17972 assert_eq!(
17973 vector.get("profile").and_then(Value::as_str),
17974 Some("link.md-v2-changeset-withheld")
17975 );
17976 let canonical =
17977 crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
17978 let expected = STANDARD
17979 .decode(
17980 vector
17981 .get("canonical_base64")
17982 .and_then(Value::as_str)
17983 .unwrap(),
17984 )
17985 .unwrap();
17986 assert_eq!(canonical, expected);
17987 assert_eq!(
17988 crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
17989 vector.get("domain_hash").and_then(Value::as_str).unwrap()
17990 );
17991 }
17992
17993 #[test]
17994 fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
17995 let remote = signed_remote_fixture();
17996 let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
17997 let legacy_item = legacy.entries.first().unwrap();
17998 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
17999 let body = json!({
18000 "actor_ref": "a".repeat(64),
18001 "asset_root": Value::Null,
18002 "brain": remote.key.multikey,
18003 "changes_sha256": "b".repeat(64),
18004 "control_revision": "c".repeat(64),
18005 "materializer": "dbmd-projection-v1",
18006 "op": "changeset",
18007 "parent_asset_root": Value::Null,
18008 "parent_commit": Value::Null,
18009 "parent_root": Value::Null,
18010 "prev_entry_hash": Value::Null,
18011 "public_key": remote.key.public_key_spki,
18012 "seq": 1,
18013 "signer_epoch": 1,
18014 "state_root": "d".repeat(64),
18015 "ts": "2026-08-19T12:00:00.000Z",
18016 "v": 2,
18017 "v1_bridge": {
18018 "feed_hash": legacy_item.hash,
18019 "head_seq": legacy_item.entry.seq,
18020 "pack_sha256": legacy_item.entry.pack_sha256,
18021 },
18022 });
18023 let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
18024 let mut signed = body.as_object().unwrap().clone();
18025 signed.insert(
18026 "sig".to_string(),
18027 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
18028 );
18029 let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
18030 let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
18031 let feed_hash = content_sha256(&raw);
18032 let pointer = V2PointerBody {
18033 v: 2,
18034 brain: TEST_BRAIN_ID.to_string(),
18035 seq: 1,
18036 commit_hash: commit_hash.clone(),
18037 feed_hash: feed_hash.clone(),
18038 content_root: Some("d".repeat(64)),
18039 asset_root: None,
18040 materializer: "dbmd-projection-v1".to_string(),
18041 signer_epoch: 1,
18042 control_revision: "c".repeat(64),
18043 backup_preparation: "e".repeat(64),
18044 prior_pointer_hash: None,
18045 signed_at: "2026-08-19T12:00:00.000Z".to_string(),
18046 };
18047 let v2_page = json!({
18048 "v": 2,
18049 "head_seq": 1,
18050 "head_commit_hash": commit_hash,
18051 "head_feed_hash": feed_hash,
18052 "entries": [{
18053 "seq": 1,
18054 "commit_hash": pointer.commit_hash,
18055 "feed_hash": pointer.feed_hash,
18056 "bytes_base64": STANDARD.encode(&raw),
18057 }],
18058 "next_after": 1,
18059 "complete": true,
18060 })
18061 .to_string();
18062 let identity = V2HeadIdentity {
18063 custody: "self".to_string(),
18064 fingerprint: remote.identity.fingerprint.clone(),
18065 public_key_spki: remote.identity.public_key_spki.clone(),
18066 previous: Vec::new(),
18067 rotations: Vec::new(),
18068 };
18069 let checkpoint = TrustState {
18070 v: 2,
18071 origin: "unused".to_string(),
18072 requested: TEST_BRAIN_ID.to_string(),
18073 brain: TEST_BRAIN_ID.to_string(),
18074 home: None,
18075 anchor: remote.key.multikey.clone(),
18076 current: remote.key.multikey,
18077 head_seq: legacy_item.entry.seq,
18078 feed_hash: Some(legacy_item.hash.clone()),
18079 rotations: Vec::new(),
18080 hub_signer: None,
18081 protocol_profile: None,
18082 };
18083 let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
18084 let state = tempfile::tempdir().unwrap();
18085 let cfg = test_hub_config(hub, state.path().to_path_buf());
18086 verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
18087 server.join().unwrap();
18088
18089 let mut wrong = checkpoint;
18090 wrong.feed_hash = Some("0".repeat(64));
18091 let (hub, server) = scripted_json_hub(vec![(
18092 200,
18093 json!({
18094 "v": 2,
18095 "head_seq": 1,
18096 "head_commit_hash": pointer.commit_hash,
18097 "head_feed_hash": pointer.feed_hash,
18098 "entries": [{
18099 "seq": 1,
18100 "commit_hash": pointer.commit_hash,
18101 "feed_hash": pointer.feed_hash,
18102 "bytes_base64": STANDARD.encode(&raw),
18103 }],
18104 "next_after": 1,
18105 "complete": true,
18106 })
18107 .to_string(),
18108 )]);
18109 let state = tempfile::tempdir().unwrap();
18110 let cfg = test_hub_config(hub, state.path().to_path_buf());
18111 assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
18112 server.join().unwrap();
18113 }
18114
18115 #[test]
18116 fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
18117 let rng = ring::rand::SystemRandom::new();
18118 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18119 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
18120 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18121 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
18122 let (old_spki, old_multikey) = public_identity_for(&old);
18123 let (new_spki, new_multikey) = public_identity_for(&new);
18124 let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
18125 v: 1,
18126 op: "rotate",
18127 brain: &old_multikey,
18128 public_key: &old_spki,
18129 new_brain: &new_multikey,
18130 new_public_key: &new_spki,
18131 prior_head_seq: 1,
18132 prior_feed_hash: Some(&"9".repeat(64)),
18133 ts: "2026-08-19T12:01:00.000Z".to_string(),
18134 })
18135 .unwrap();
18136 let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
18137 let rotation = format!(
18138 "{},\"sig\":\"{}\"}}",
18139 &rotation_unsigned[..rotation_unsigned.len() - 1],
18140 rotation_sig
18141 );
18142 let identity = V2HeadIdentity {
18143 custody: "self".to_string(),
18144 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
18145 public_key_spki: new_spki.clone(),
18146 previous: vec![V2PreviousIdentity {
18147 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
18148 public_key_spki: old_spki.clone(),
18149 }],
18150 rotations: vec![rotation],
18151 };
18152 let commit = |seq: u64,
18153 epoch: u64,
18154 multikey: &str,
18155 spki: &str,
18156 pair: &ring::signature::Ed25519KeyPair| {
18157 let value = json!({
18158 "actor_ref": "a".repeat(64),
18159 "asset_root": Value::Null,
18160 "brain": multikey,
18161 "changes_sha256": "b".repeat(64),
18162 "control_revision": "c".repeat(64),
18163 "materializer": "dbmd-projection-v1",
18164 "op": "changeset",
18165 "parent_asset_root": Value::Null,
18166 "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
18167 "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
18168 "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
18169 "public_key": spki,
18170 "seq": seq,
18171 "signer_epoch": epoch,
18172 "state_root": "1".repeat(64),
18173 "ts": "2026-08-19T12:00:00.000Z",
18174 "v": 2,
18175 "v1_bridge": Value::Null,
18176 });
18177 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
18178 let mut object = value.as_object().unwrap().clone();
18179 object.insert(
18180 "sig".to_string(),
18181 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
18182 );
18183 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
18184 };
18185
18186 assert!(verified_v2_commit_object(
18187 &commit(1, 1, &old_multikey, &old_spki, &old),
18188 &identity,
18189 )
18190 .is_ok());
18191 assert!(verified_v2_commit_object(
18192 &commit(2, 2, &new_multikey, &new_spki, &new),
18193 &identity,
18194 )
18195 .is_ok());
18196 assert!(verified_v2_commit_object(
18197 &commit(2, 1, &old_multikey, &old_spki, &old),
18198 &identity,
18199 )
18200 .is_err());
18201 assert!(verified_v2_commit_object(
18202 &commit(1, 2, &new_multikey, &new_spki, &new),
18203 &identity,
18204 )
18205 .is_err());
18206 }
18207
18208 #[test]
18209 fn a_self_custody_entry_verifies_like_any_hub_entry() {
18210 let rng = ring::rand::SystemRandom::new();
18211 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18212 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18213 let (spki, multikey) = public_identity_for(&pair);
18214 let key = AgentSigningKey {
18215 pkcs8: pkcs8.as_ref().to_vec(),
18216 multikey: multikey.clone(),
18217 public_key_spki: spki.clone(),
18218 };
18219 let files = vec![WireFeedFile {
18220 path: "DB.md".to_string(),
18221 sha256: "a".repeat(64),
18222 bytes: 3,
18223 }];
18224 let raw = self_custody_entry(
18225 &key,
18226 1,
18227 "2026-07-23T12:00:00.000Z".to_string(),
18228 &"c".repeat(64),
18229 &files,
18230 None,
18231 )
18232 .unwrap();
18233 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
18237 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
18238 let item = FeedItem { hash, entry };
18239 let identity = FeedIdentity {
18240 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
18241 public_key_spki: spki,
18242 previous: Vec::new(),
18243 rotations: Vec::new(),
18244 };
18245 assert!(verify_feed_item(&item, &identity).is_ok());
18246 }
18247
18248 #[test]
18249 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
18250 let rng = ring::rand::SystemRandom::new();
18251 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18252 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
18253 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18254 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
18255 let (old_spki, old_multikey) = public_identity_for(&old);
18256 let (new_spki, new_multikey) = public_identity_for(&new);
18257 let unsigned = serde_json::to_string(&UnsignedRotation {
18258 v: 1,
18259 op: "rotate",
18260 brain: &old_multikey,
18261 public_key: &old_spki,
18262 new_brain: &new_multikey,
18263 new_public_key: &new_spki,
18264 prior_head_seq: 1,
18265 prior_feed_hash: Some(&"a".repeat(64)),
18266 ts: "2026-07-30T12:00:00.000Z".to_string(),
18267 })
18268 .unwrap();
18269 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
18270 let rotation = format!(
18271 "{},\"sig\":\"{}\"}}",
18272 &unsigned[..unsigned.len() - 1],
18273 signature
18274 );
18275 let identity = FeedIdentity {
18276 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
18277 public_key_spki: new_spki,
18278 previous: vec![PreviousIdentity {
18279 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
18280 public_key_spki: old_spki,
18281 }],
18282 rotations: vec![rotation],
18283 };
18284 let pin = TrustState {
18285 v: 2,
18286 origin: "https://hub.example".to_string(),
18287 requested: "brain".to_string(),
18288 brain: "brain".to_string(),
18289 home: None,
18290 anchor: old_multikey.clone(),
18291 current: old_multikey.clone(),
18292 head_seq: 1,
18293 feed_hash: Some("a".repeat(64)),
18294 rotations: Vec::new(),
18295 hub_signer: None,
18296 protocol_profile: None,
18297 };
18298 assert_eq!(
18299 verify_identity_chain(&identity, Some(&pin)).unwrap(),
18300 old_multikey
18301 );
18302 let mut accepted = pin.clone();
18303 accepted.current = new_multikey.clone();
18304 accepted.rotations = identity.rotations.clone();
18305 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
18306 v: 1,
18307 op: "rotate",
18308 brain: &old_multikey,
18309 public_key: &identity.previous[0].public_key_spki,
18310 new_brain: &new_multikey,
18311 new_public_key: &identity.public_key_spki,
18312 prior_head_seq: 1,
18313 prior_feed_hash: Some(&"a".repeat(64)),
18314 ts: "2026-07-30T12:00:01.000Z".to_string(),
18315 })
18316 .unwrap();
18317 let alternate_signature =
18318 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
18319 let mut rewritten = identity.clone();
18320 rewritten.rotations[0] = format!(
18321 "{},\"sig\":\"{}\"}}",
18322 &alternate_unsigned[..alternate_unsigned.len() - 1],
18323 alternate_signature
18324 );
18325 assert!(
18326 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
18327 "an alternate valid statement must not rewrite accepted history"
18328 );
18329
18330 let mut stale_entry = FeedEntry {
18331 v: 1,
18332 seq: 2,
18333 ts: "2026-07-30T12:01:00.000Z".to_string(),
18334 brain: pin.current.clone(),
18335 public_key: identity.previous[0].public_key_spki.clone(),
18336 kind: "push".to_string(),
18337 op: "snapshot".to_string(),
18338 pack_sha256: "b".repeat(64),
18339 files: Vec::new(),
18340 removed: Vec::new(),
18341 prev_entry_hash: pin.feed_hash.clone(),
18342 sig: String::new(),
18343 };
18344 let stale_unsigned = UnsignedFeedEntry {
18345 v: stale_entry.v,
18346 seq: stale_entry.seq,
18347 ts: &stale_entry.ts,
18348 brain: &stale_entry.brain,
18349 public_key: &stale_entry.public_key,
18350 kind: &stale_entry.kind,
18351 op: &stale_entry.op,
18352 pack_sha256: &stale_entry.pack_sha256,
18353 files: &stale_entry.files,
18354 removed: &stale_entry.removed,
18355 prev_entry_hash: &stale_entry.prev_entry_hash,
18356 };
18357 stale_entry.sig = URL_SAFE_NO_PAD.encode(
18358 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
18359 .as_ref(),
18360 );
18361 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
18362 stale_exact.push(b'\n');
18363 let stale_item = FeedItem {
18364 hash: content_sha256(&stale_exact),
18365 entry: stale_entry,
18366 };
18367 assert!(
18368 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
18369 .is_err(),
18370 "a key retired before the checkpoint must never append after it"
18371 );
18372 assert!(
18373 verify_feed_item(&stale_item, &identity).is_err(),
18374 "an old key must never append after its signed rotation boundary"
18375 );
18376
18377 let mut missing = identity.clone();
18378 missing.rotations.clear();
18379 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
18380
18381 let mut tampered = identity;
18382 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
18383 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
18384 }
18385
18386 #[cfg(unix)]
18387 #[test]
18388 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
18389 use std::os::unix::fs::symlink;
18390
18391 let dir = tempfile::tempdir().unwrap();
18392 let target = dir.path().join("valuable.txt");
18393 let planted = dir.path().join("agent.key");
18394 std::fs::write(&target, "do not overwrite").unwrap();
18395 symlink(&target, &planted).unwrap();
18396
18397 assert!(matches!(
18398 generate_agent_key(&planted),
18399 Err(LinkError::BadAgentKey { .. })
18400 ));
18401 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
18402 }
18403
18404 #[cfg(unix)]
18405 #[test]
18406 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
18407 use std::os::unix::fs::symlink;
18408
18409 let root = tempfile::tempdir().unwrap();
18410 let outside = tempfile::tempdir().unwrap();
18411 symlink(outside.path(), root.path().join("redirect")).unwrap();
18412
18413 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
18414 assert!(!outside.path().join("agent.key").exists());
18415 }
18416
18417 #[test]
18420 fn address_bare_brain_with_and_without_sigil() {
18421 for raw in ["@acme-ops", "acme-ops"] {
18422 let a = Address::parse(raw).expect(raw);
18423 assert_eq!(a.brain, "acme-ops");
18424 assert_eq!(a.target, None);
18425 }
18426 }
18427
18428 #[test]
18429 fn address_ulid_target_parses_as_id() {
18430 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
18431 assert_eq!(a.brain, "acme");
18432 assert_eq!(
18433 a.target,
18434 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
18435 );
18436 }
18437
18438 #[test]
18439 fn address_md_path_target_parses_as_path() {
18440 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
18441 assert_eq!(
18442 a.target,
18443 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
18444 );
18445 }
18446
18447 #[test]
18448 fn address_rejects_malformed_forms() {
18449 for raw in [
18450 "",
18451 "@",
18452 "@/x",
18453 "@acme/",
18454 "@acme/../etc/passwd",
18455 "@acme/records/.hidden.md",
18456 "@ACME", "@acme/notes/x.txt", "@a b", ] {
18460 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
18461 }
18462 }
18463
18464 #[test]
18467 fn safe_paths_accept_store_shapes_and_reject_escapes() {
18468 for ok in [
18469 "DB.md",
18470 "assets.jsonl",
18471 "records/clients/lumio.md",
18472 "sources/emails/2026/07/x.md",
18473 ] {
18474 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
18475 }
18476 for bad in [
18477 "",
18478 "/etc/passwd",
18479 "../up.md",
18480 "records/../../up.md",
18481 "records//x.md",
18482 ".dbmd/config",
18483 "records/.hidden/x.md",
18484 "records/a b.md",
18485 "records\\win.md",
18486 ] {
18487 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
18488 }
18489 }
18490
18491 #[cfg(unix)]
18492 #[test]
18493 fn opened_destination_capability_survives_an_ancestor_path_swap() {
18494 use std::os::unix::fs::symlink;
18495
18496 let work = tempfile::tempdir().unwrap();
18497 let outside = tempfile::tempdir().unwrap();
18498 let original = work.path().join("destination");
18499 let moved = work.path().join("destination-moved");
18500 let directory = open_or_create_dir_nofollow(&original).unwrap();
18501
18502 std::fs::rename(&original, &moved).unwrap();
18503 symlink(outside.path(), &original).unwrap();
18504 write_pull_entries_beneath_dir(
18505 &directory,
18506 &[("records/note.md".to_string(), b"held inode".to_vec())],
18507 )
18508 .unwrap();
18509
18510 assert_eq!(
18511 std::fs::read(moved.join("records/note.md")).unwrap(),
18512 b"held inode"
18513 );
18514 assert!(!outside.path().join("records/note.md").exists());
18515 }
18516
18517 #[test]
18521 fn hub_config_flag_beats_file_and_requires_some_source() {
18522 let dir = tempfile::tempdir().unwrap();
18523 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
18524 std::fs::write(
18525 dir.path().join(CONFIG_REL_PATH),
18526 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
18527 )
18528 .unwrap();
18529
18530 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
18531 assert_eq!(from_flag.hub, "https://flag.example.com");
18532
18533 let from_file = hub_config(None, dir.path()).unwrap();
18534 assert_eq!(from_file.hub, "https://file.example.com");
18535
18536 let none = hub_config(None, tempfile::tempdir().unwrap().path());
18537 assert!(matches!(none, Err(LinkError::NoHub)));
18538 }
18539
18540 #[test]
18541 fn https_guard_allows_loopback_only_for_plain_http() {
18542 assert!(assert_safe_hub("https://hub.example.com").is_ok());
18543 assert!(assert_safe_hub("http://localhost:3000").is_ok());
18544 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
18545 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
18546 assert!(matches!(
18547 assert_safe_hub("http://hub.example.com"),
18548 Err(LinkError::UnsafeHub { .. })
18549 ));
18550 assert!(matches!(
18551 assert_safe_hub("hub.example.com"),
18552 Err(LinkError::UnsafeHub { .. })
18553 ));
18554 assert!(matches!(
18555 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
18556 Err(LinkError::UnsafeHub { .. })
18557 ));
18558 assert!(matches!(
18559 assert_safe_hub("https://hub.example.com@attacker.example"),
18560 Err(LinkError::UnsafeHub { .. })
18561 ));
18562 assert!(matches!(
18563 assert_safe_hub("https://hub.example.com/base"),
18564 Err(LinkError::UnsafeHub { .. })
18565 ));
18566 }
18567
18568 #[test]
18569 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
18570 for blocked in [
18571 "127.0.0.1",
18572 "10.0.0.1",
18573 "100.64.0.1",
18574 "169.254.169.254",
18575 "172.16.0.1",
18576 "192.168.0.1",
18577 "192.88.99.1",
18578 "198.18.0.1",
18579 "203.0.113.1",
18580 "::1",
18581 "fe80::1",
18582 "fd00::1",
18583 "2001:db8::1",
18584 "2001:1::1",
18585 "2002:7f00:1::",
18586 "3fff::1",
18587 ] {
18588 assert!(
18589 !is_public_registry_ip(blocked.parse().unwrap()),
18590 "must block {blocked}"
18591 );
18592 }
18593 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
18594 assert!(is_public_registry_ip(
18595 "2606:4700:4700::1111".parse().unwrap()
18596 ));
18597 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
18598 }
18599
18600 #[test]
18601 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
18602 use ureq::Resolver as _;
18603
18604 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
18605 let resolver = PinnedRegistryResolver {
18606 netloc: "home.example:443".to_string(),
18607 addresses: vec![pinned],
18608 };
18609 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
18610 assert!(resolver.resolve("127.0.0.1:443").is_err());
18611 assert_eq!(
18612 resolver.resolve("home.example:443").unwrap(),
18613 vec![pinned],
18614 "subsequent connects reuse the validated answer instead of DNS"
18615 );
18616 }
18617
18618 #[test]
18619 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
18620 let cfg = HubConfig {
18621 hub: "https://hub.example".to_string(),
18622 key: None,
18623 agent_key: None,
18624 brain_key: None,
18625 state_dir: tempfile::tempdir().unwrap().keep(),
18626 store_selected: false,
18627 };
18628 assert!(
18629 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
18630 "a production hub must not turn its presigned URL into an SSRF primitive"
18631 );
18632
18633 let store_selected = HubConfig {
18634 hub: "https://127.0.0.1".to_string(),
18635 store_selected: true,
18636 ..cfg
18637 };
18638 assert!(
18639 hub_agent(&store_selected).is_err(),
18640 "bytes in a cloned store must not select a private-network hub"
18641 );
18642 }
18643
18644 #[test]
18645 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
18646 assert_eq!(
18647 one_past_bounded_limit(MAX_PACK_BYTES),
18648 Some(MAX_PACK_BYTES + 1),
18649 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
18650 );
18651 assert_eq!(
18652 presigned_download_read_limit(),
18653 MAX_PACK_BYTES + 1,
18654 "the presigned reader is capped by the client constant, not a hub response"
18655 );
18656 assert_eq!(
18657 one_past_bounded_limit(u64::MAX),
18658 None,
18659 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
18660 );
18661 }
18662
18663 #[test]
18664 fn https_guard_matches_the_scheme_case_insensitively() {
18665 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
18668 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
18669 assert!(matches!(
18671 assert_safe_hub("HTTP://hub.example.com"),
18672 Err(LinkError::UnsafeHub { .. })
18673 ));
18674 }
18675
18676 #[test]
18677 fn clean_key_refuses_paste_artifacts_without_echoing() {
18678 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
18679 for bad in ["vc account", "vc\naccount", "ключ", ""] {
18680 let err = clean_key(bad).unwrap_err();
18681 assert!(matches!(err, LinkError::BadKey));
18682 assert!(
18683 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
18684 "error must not echo the key"
18685 );
18686 }
18687 }
18688
18689 fn dead_hub() -> HubConfig {
18695 HubConfig {
18696 hub: "http://127.0.0.1:9".to_string(),
18697 key: Some("k".to_string()),
18698 agent_key: None,
18699 brain_key: None,
18700 state_dir: PathBuf::from("."),
18701 store_selected: false,
18702 }
18703 }
18704
18705 #[test]
18706 fn request_retries_a_connection_failure_before_sending() {
18707 use std::io::{Read as _, Write as _};
18708 use std::net::TcpListener;
18709 use std::thread;
18710 use std::time::Duration;
18711
18712 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
18713 let address = probe.local_addr().unwrap();
18714 drop(probe);
18715 let server = thread::spawn(move || {
18716 thread::sleep(Duration::from_millis(40));
18717 let listener = TcpListener::bind(address).unwrap();
18718 let (mut stream, _) = listener.accept().unwrap();
18719 let mut request_bytes = [0_u8; 1024];
18720 let _ = stream.read(&mut request_bytes).unwrap();
18721 stream
18722 .write_all(
18723 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18724 )
18725 .unwrap();
18726 });
18727 let cfg = HubConfig {
18728 hub: format!("http://{address}"),
18729 key: None,
18730 agent_key: None,
18731 brain_key: None,
18732 state_dir: tempfile::tempdir().unwrap().keep(),
18733 store_selected: false,
18734 };
18735
18736 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
18737 assert_eq!(response.status, 200);
18738 assert_eq!(response.body, Some(json!({ "ok": true })));
18739 server.join().unwrap();
18740 }
18741
18742 #[test]
18743 fn a_commit_goes_back_for_a_receipt_it_lost() {
18744 use std::io::Write as _;
18745 use std::net::TcpListener;
18746 use std::thread;
18747
18748 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18754 let address = listener.local_addr().unwrap();
18755 let server = thread::spawn(move || {
18756 let (mut first, _) = listener.accept().unwrap();
18758 drain_test_http_request(&mut first);
18759 first
18760 .write_all(
18761 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
18762 )
18763 .unwrap();
18764 drop(first);
18765 let (mut second, _) = listener.accept().unwrap();
18767 drain_test_http_request(&mut second);
18768 second
18769 .write_all(
18770 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\"}",
18771 )
18772 .unwrap();
18773 });
18774 let cfg = HubConfig {
18775 hub: format!("http://{address}"),
18776 key: Some("k".to_string()),
18777 agent_key: None,
18778 brain_key: None,
18779 state_dir: tempfile::tempdir().unwrap().keep(),
18780 store_selected: false,
18781 };
18782
18783 let response = request_patient(
18784 &cfg,
18785 "POST",
18786 "/api/hub/brains/b/v2/commits",
18787 Some(&json!({ "mutation_id": "dbmd-1" })),
18788 Auth::Required,
18789 )
18790 .expect("the receipt is collected on the second ask");
18791 assert_eq!(response.status, 200);
18792 assert_eq!(
18793 response
18794 .body
18795 .as_ref()
18796 .and_then(|value| value.get("outcome"))
18797 .and_then(Value::as_str),
18798 Some("converged"),
18799 "an already-applied mutation answers with its receipt"
18800 );
18801 server.join().unwrap();
18802 }
18803
18804 #[test]
18805 fn a_patient_commit_waits_for_its_typed_post_acceptance_receipt_lag() {
18806 use std::io::Write as _;
18807 use std::net::TcpListener;
18808 use std::thread;
18809
18810 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18811 let address = listener.local_addr().unwrap();
18812 let server = thread::spawn(move || {
18813 let lag = br#"{"error":"validation recovery state is not at the source head","details":{"code":"validation_index_catching_up"}}"#;
18814 let receipt = br#"{"v":2,"outcome":"converged"}"#;
18815 for (status, body) in [
18816 ("422 Unprocessable Entity", lag.as_slice()),
18817 ("200 OK", receipt.as_slice()),
18818 ] {
18819 let (mut stream, _) = listener.accept().unwrap();
18820 drain_test_http_request(&mut stream);
18821 write!(
18822 stream,
18823 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
18824 body.len()
18825 )
18826 .unwrap();
18827 stream.write_all(body).unwrap();
18828 }
18829 });
18830 let cfg = HubConfig {
18831 hub: format!("http://{address}"),
18832 key: Some("k".to_string()),
18833 agent_key: None,
18834 brain_key: None,
18835 state_dir: tempfile::tempdir().unwrap().keep(),
18836 store_selected: false,
18837 };
18838
18839 let response = request_patient(
18840 &cfg,
18841 "POST",
18842 "/api/hub/brains/b/v2/commits",
18843 Some(&json!({ "mutation_id": "dbmd-1" })),
18844 Auth::Required,
18845 )
18846 .expect("typed projection lag is retried until the exact receipt is available");
18847 assert_eq!(response.status, 200);
18848 assert_eq!(
18849 response
18850 .body
18851 .as_ref()
18852 .and_then(|value| value.get("outcome"))
18853 .and_then(Value::as_str),
18854 Some("converged")
18855 );
18856 server.join().unwrap();
18857 }
18858
18859 #[test]
18860 fn a_mutation_body_that_dies_mid_stream_is_a_transport_failure() {
18861 use std::io::{Read as _, Write as _};
18862 use std::net::TcpListener;
18863 use std::thread;
18864
18865 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18871 let address = listener.local_addr().unwrap();
18872 let server = thread::spawn(move || {
18873 let (mut stream, _) = listener.accept().unwrap();
18874 let mut request_bytes = [0_u8; 1024];
18875 let _ = stream.read(&mut request_bytes).unwrap();
18876 stream
18878 .write_all(
18879 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18880 )
18881 .unwrap();
18882 });
18883 let cfg = HubConfig {
18884 hub: format!("http://{address}"),
18885 key: None,
18886 agent_key: None,
18887 brain_key: None,
18888 state_dir: tempfile::tempdir().unwrap().keep(),
18889 store_selected: false,
18890 };
18891
18892 let error = request(&cfg, "POST", "/truncated", None, Auth::None)
18893 .expect_err("a truncated body must not read as success");
18894 match error {
18895 LinkError::Transport { hub, .. } => {
18896 assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
18897 }
18898 other => panic!("expected a transport failure, got {other:?}"),
18899 }
18900 server.join().unwrap();
18901 }
18902
18903 #[test]
18904 fn a_safe_get_retries_when_its_body_dies_mid_stream() {
18905 use std::io::{Read as _, Write as _};
18906 use std::net::{TcpListener, TcpStream};
18907 use std::thread;
18908
18909 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18910 let address = listener.local_addr().unwrap();
18911 let server = thread::spawn(move || {
18912 let read_request = |stream: &mut TcpStream| {
18913 let mut request = Vec::new();
18914 let mut bytes = [0_u8; 1024];
18915 loop {
18916 let read = stream.read(&mut bytes).unwrap();
18917 if read == 0 {
18918 break;
18919 }
18920 request.extend_from_slice(&bytes[..read]);
18921 let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
18922 else {
18923 continue;
18924 };
18925 let headers = String::from_utf8_lossy(&request[..header_end]);
18926 let content_length = headers
18927 .lines()
18928 .find_map(|line| {
18929 let (name, value) = line.split_once(':')?;
18930 name.eq_ignore_ascii_case("content-length")
18931 .then(|| value.trim().parse::<usize>().ok())
18932 .flatten()
18933 })
18934 .unwrap_or(0);
18935 if request.len() >= header_end + 4 + content_length {
18936 break;
18937 }
18938 }
18939 };
18940 let (mut first, _) = listener.accept().unwrap();
18941 read_request(&mut first);
18942 first
18943 .write_all(
18944 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18945 )
18946 .unwrap();
18947 drop(first);
18948
18949 let (mut second, _) = listener.accept().unwrap();
18950 read_request(&mut second);
18951 second
18952 .write_all(
18953 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18954 )
18955 .unwrap();
18956 });
18957 let cfg = HubConfig {
18958 hub: format!("http://{address}"),
18959 key: None,
18960 agent_key: None,
18961 brain_key: None,
18962 state_dir: tempfile::tempdir().unwrap().keep(),
18963 store_selected: false,
18964 };
18965
18966 let response = request(&cfg, "GET", "/retry-body", None, Auth::None)
18967 .expect("a safe read retries the interrupted body");
18968 assert_eq!(response.status, 200);
18969 assert_eq!(response.body, Some(json!({ "ok": true })));
18970 server.join().unwrap();
18971 }
18972
18973 #[test]
18974 fn an_explicit_read_only_post_retries_when_its_body_dies_mid_stream() {
18975 use std::io::{Read as _, Write as _};
18976 use std::net::{TcpListener, TcpStream};
18977 use std::thread;
18978
18979 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18980 let address = listener.local_addr().unwrap();
18981 let server = thread::spawn(move || {
18982 let read_request = |stream: &mut TcpStream| {
18983 let mut request = Vec::new();
18984 let mut bytes = [0_u8; 1024];
18985 loop {
18986 let read = stream.read(&mut bytes).unwrap();
18987 if read == 0 {
18988 break;
18989 }
18990 request.extend_from_slice(&bytes[..read]);
18991 let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
18992 else {
18993 continue;
18994 };
18995 let headers = String::from_utf8_lossy(&request[..header_end]);
18996 let content_length = headers
18997 .lines()
18998 .find_map(|line| {
18999 let (name, value) = line.split_once(':')?;
19000 name.eq_ignore_ascii_case("content-length")
19001 .then(|| value.trim().parse::<usize>().ok())
19002 .flatten()
19003 })
19004 .unwrap_or(0);
19005 if request.len() >= header_end + 4 + content_length {
19006 break;
19007 }
19008 }
19009 };
19010 let (mut first, _) = listener.accept().unwrap();
19011 read_request(&mut first);
19012 first
19013 .write_all(
19014 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
19015 )
19016 .unwrap();
19017 drop(first);
19018
19019 let (mut second, _) = listener.accept().unwrap();
19020 read_request(&mut second);
19021 second
19022 .write_all(
19023 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
19024 )
19025 .unwrap();
19026 });
19027 let cfg = HubConfig {
19028 hub: format!("http://{address}"),
19029 key: None,
19030 agent_key: None,
19031 brain_key: None,
19032 state_dir: tempfile::tempdir().unwrap().keep(),
19033 store_selected: false,
19034 };
19035
19036 let response = request_raw_retryable_read(
19037 &cfg,
19038 "POST",
19039 "/v2/stream",
19040 Some(&json!({ "files": ["proof"] })),
19041 Auth::None,
19042 1_024,
19043 )
19044 .expect("an explicitly safe POST retries the interrupted body");
19045 assert_eq!(response.status, 200);
19046 assert_eq!(
19047 serde_json::from_slice::<Value>(&response.body).unwrap(),
19048 json!({ "ok": true })
19049 );
19050 server.join().unwrap();
19051 }
19052
19053 #[test]
19054 fn object_store_transport_errors_never_render_presigned_urls() {
19055 use std::net::TcpListener;
19056
19057 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19058 let address = listener.local_addr().unwrap();
19059 drop(listener);
19060 let signature = "do-not-render-this-presigned-signature";
19061 let raw =
19062 format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
19063 let error = ureq::get(&raw)
19064 .timeout(std::time::Duration::from_millis(250))
19065 .call()
19066 .expect_err("the closed local port must fail");
19067 let ureq::Error::Transport(transport) = error else {
19068 panic!("expected a transport failure");
19069 };
19070
19071 let rendered = object_store_transport_error(transport).to_string();
19072 assert!(rendered.contains("the object store"));
19073 assert!(rendered.contains("network error"));
19074 assert!(!rendered.contains(&raw));
19075 assert!(!rendered.contains(signature));
19076 assert!(!rendered.contains("X-Amz-"));
19077 }
19078
19079 #[test]
19080 fn endpoint_cap_refuses_a_body_before_json_parsing() {
19081 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
19082 let cfg = HubConfig {
19083 hub,
19084 key: None,
19085 agent_key: None,
19086 brain_key: None,
19087 state_dir: tempfile::tempdir().unwrap().keep(),
19088 store_selected: false,
19089 };
19090
19091 assert!(matches!(
19092 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
19093 Err(LinkError::ResponseTooLarge { .. })
19094 ));
19095 server.join().unwrap();
19096 }
19097
19098 #[test]
19099 fn overall_deadline_stops_a_dribbled_response_body() {
19100 use std::io::{Read as _, Write as _};
19101 use std::net::TcpListener;
19102 use std::time::{Duration, Instant};
19103
19104 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19105 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
19106 let server = std::thread::spawn(move || {
19107 let (mut stream, _) = listener.accept().unwrap();
19108 let mut request = [0_u8; 1024];
19109 let _ = stream.read(&mut request);
19110 stream
19111 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
19112 .unwrap();
19113 for byte in [b'x'; 32] {
19114 if stream.write_all(&[byte]).is_err() {
19115 break;
19116 }
19117 std::thread::sleep(Duration::from_millis(40));
19118 }
19119 });
19120 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
19121 let started = Instant::now();
19122 let response = http.get(&url).call().unwrap();
19123 let mut body = Vec::new();
19124 let error = response
19125 .into_reader()
19126 .read_to_end(&mut body)
19127 .expect_err("per-read progress must not reset the overall deadline");
19128 assert!(
19129 started.elapsed() < Duration::from_millis(700),
19130 "dribbled body exceeded the wall-clock budget: {error}"
19131 );
19132 server.join().unwrap();
19133 }
19134
19135 #[test]
19136 fn overall_deadline_stops_a_stalled_upload() {
19137 use std::net::TcpListener;
19138 use std::time::{Duration, Instant};
19139
19140 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19141 let url = format!("http://{}/upload", listener.local_addr().unwrap());
19142 let server = std::thread::spawn(move || {
19143 let (_stream, _) = listener.accept().unwrap();
19144 std::thread::sleep(Duration::from_millis(600));
19147 });
19148 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
19149 let body = vec![0x5a; 32 * 1024 * 1024];
19150 let started = Instant::now();
19151 let error = http
19152 .put(&url)
19153 .send_bytes(&body)
19154 .expect_err("stalled request-body writes must time out");
19155 assert!(
19156 started.elapsed() < Duration::from_millis(700),
19157 "stalled upload exceeded the wall-clock budget: {error}"
19158 );
19159 server.join().unwrap();
19160 }
19161
19162 #[test]
19163 fn presigned_source_retries_share_one_upload_deadline() {
19164 use std::net::TcpListener;
19165 use std::time::{Duration, Instant};
19166
19167 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19168 let address = listener.local_addr().unwrap();
19169 let signature = "do-not-render-this-stalled-upload-signature";
19170 let url = format!(
19171 "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
19172 );
19173 let server = std::thread::spawn(move || {
19174 let (_stream, _) = listener.accept().unwrap();
19175 std::thread::sleep(Duration::from_millis(600));
19179 });
19180
19181 let directory = tempfile::tempdir().unwrap();
19182 std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
19183 std::fs::create_dir(directory.path().join("records")).unwrap();
19184 let relative = "records/stalled.bin";
19185 let bytes = vec![0x5a; 32 * 1024 * 1024];
19186 std::fs::write(directory.path().join(relative), &bytes).unwrap();
19187 let store = Store::open_strict(directory.path()).unwrap();
19188 let cfg = HubConfig {
19189 hub: format!("http://{address}"),
19190 key: None,
19191 agent_key: None,
19192 brain_key: None,
19193 state_dir: tempfile::tempdir().unwrap().keep(),
19194 store_selected: false,
19195 };
19196 let source = V2UploadSource {
19197 path: relative.to_string(),
19198 bytes: bytes.len() as u64,
19199 };
19200
19201 let started = Instant::now();
19202 let error = put_presigned_source_with_budget(
19203 &cfg,
19204 &url,
19205 &json!({ "content-length": source.bytes.to_string() }),
19206 &store,
19207 &source,
19208 None,
19209 Duration::from_millis(150),
19210 )
19211 .expect_err("a black-holed upload must leave at its shared deadline");
19212 assert!(
19213 started.elapsed() < Duration::from_millis(700),
19214 "presigned retries exceeded their shared budget: {error}"
19215 );
19216 let rendered = error.to_string();
19217 assert!(rendered.contains("the object store"));
19218 assert!(!rendered.contains(&url));
19219 assert!(!rendered.contains(signature));
19220 server.join().unwrap();
19221 }
19222
19223 #[test]
19224 fn verb_entry_gates_accept_the_hub_ref_shapes() {
19225 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
19226 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
19227 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
19228 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
19229 }
19230 }
19231
19232 #[test]
19233 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
19234 let cfg = dead_hub();
19235 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
19236 assert!(
19237 matches!(
19238 sync_pull(&cfg, bad, None),
19239 Err(LinkError::BadAddress { .. })
19240 ),
19241 "sync_pull must refuse {bad:?}"
19242 );
19243 assert!(
19244 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
19245 "sync_push must refuse {bad:?}"
19246 );
19247 assert!(
19248 matches!(
19249 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
19250 Err(LinkError::BadAddress { .. })
19251 ),
19252 "grant_issue must refuse {bad:?}"
19253 );
19254 assert!(
19255 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
19256 "grant_list must refuse {bad:?}"
19257 );
19258 assert!(
19259 matches!(
19260 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
19261 Err(LinkError::BadAddress { .. })
19262 ),
19263 "grant_revoke must refuse brain {bad:?}"
19264 );
19265 assert!(
19266 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
19267 "head must refuse {bad:?}"
19268 );
19269 }
19270 }
19271
19272 #[test]
19273 fn grant_revoke_refuses_url_reshaping_grant_ids() {
19274 let cfg = dead_hub();
19275 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
19276 assert!(
19277 matches!(
19278 grant_revoke(&cfg, "acme", bad),
19279 Err(LinkError::BadGrantId { .. })
19280 ),
19281 "grant_revoke must refuse grant id {bad:?}"
19282 );
19283 }
19284 }
19285
19286 #[test]
19287 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
19288 let cfg = dead_hub();
19289 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
19290 assert!(
19291 matches!(
19292 propose(&cfg, bad, "intake", "hi"),
19293 Err(LinkError::BadAddress { .. })
19294 ),
19295 "propose must refuse handle {bad:?}"
19296 );
19297 }
19298 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
19299 assert!(matches!(
19300 propose(&cfg, "acme-site", "intake", &oversize),
19301 Err(LinkError::ProposeTooLarge { .. })
19302 ));
19303 assert!(matches!(
19306 propose(&cfg, "acme-site", "intake", "hi"),
19307 Err(LinkError::Transport { .. })
19308 ));
19309 }
19310
19311 #[test]
19312 fn resolve_refuses_a_hand_built_unsafe_address() {
19313 let cfg = dead_hub();
19314 for brain in ["../up", "a/b", "a?x", "a#f"] {
19315 let addr = Address {
19316 brain: brain.to_string(),
19317 target: None,
19318 };
19319 assert!(
19320 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
19321 "resolve must refuse brain {brain:?}"
19322 );
19323 }
19324 for target in [
19325 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
19326 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
19328 AddressTarget::Path("records/x.md#frag".to_string()),
19329 ] {
19330 let addr = Address {
19331 brain: "acme".to_string(),
19332 target: Some(target.clone()),
19333 };
19334 assert!(
19335 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
19336 "resolve must refuse target {target:?}"
19337 );
19338 }
19339 }
19340
19341 #[test]
19342 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
19343 let mut local = std::collections::BTreeMap::new();
19344 local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
19345 local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
19346 let mut remote = std::collections::BTreeMap::new();
19347 remote.insert(
19348 "records/a.md".to_string(),
19349 V2BaselineFile {
19350 sha256: "c".repeat(64),
19351 bytes: 1,
19352 proof: None,
19353 },
19354 );
19355 remote.insert(
19356 "records/b.md".to_string(),
19357 V2BaselineFile {
19358 sha256: "b".repeat(64),
19359 bytes: 1,
19360 proof: None,
19361 },
19362 );
19363 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
19364 }
19365
19366 #[test]
19367 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
19368 let local = std::collections::BTreeMap::new();
19369 let mut remote = std::collections::BTreeMap::new();
19370 remote.insert(
19371 "private/local.md".to_string(),
19372 V2BaselineFile {
19373 sha256: "d".repeat(64),
19374 bytes: 1,
19375 proof: None,
19376 },
19377 );
19378 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
19379 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
19380 }
19381
19382 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
19383 V2VerifiedHead {
19384 requested: TEST_BRAIN_ID.to_string(),
19385 brain_id: TEST_BRAIN_ID.to_string(),
19386 view_kind: "scoped".to_string(),
19387 view_revision: revision.to_string(),
19388 control_revision: revision.to_string(),
19389 identity: V2HeadIdentity {
19390 custody: "hub".to_string(),
19391 fingerprint: "test".to_string(),
19392 public_key_spki: "test".to_string(),
19393 previous: Vec::new(),
19394 rotations: Vec::new(),
19395 },
19396 pointer: None,
19397 trust: TrustState {
19398 v: 2,
19399 origin: "https://hub.example".to_string(),
19400 requested: TEST_BRAIN_ID.to_string(),
19401 brain: TEST_BRAIN_ID.to_string(),
19402 home: None,
19403 anchor: "ed25519:test".to_string(),
19404 current: "ed25519:test".to_string(),
19405 head_seq: 0,
19406 feed_hash: None,
19407 rotations: Vec::new(),
19408 hub_signer: None,
19409 protocol_profile: Some("link-v2".to_string()),
19410 },
19411 alias: None,
19412 }
19413 }
19414
19415 #[test]
19416 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
19417 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
19418 assert!(accepted_as_v2(&trust));
19419
19420 trust.protocol_profile = None;
19421 trust.hub_signer = Some("ed25519:hub".to_string());
19422 assert!(accepted_as_v2(&trust));
19423
19424 trust.hub_signer = None;
19425 assert!(!accepted_as_v2(&trust));
19426 }
19427
19428 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
19429 V2SyncBaseline {
19430 v: 2,
19431 origin: "https://hub.example".to_string(),
19432 brain: TEST_BRAIN_ID.to_string(),
19433 checkout_id: Some("c".repeat(64)),
19434 head_seq: Some(0),
19435 commit_hash: None,
19436 content_root: None,
19437 asset_root: None,
19438 assets: std::collections::BTreeMap::new(),
19439 view_kind: Some("scoped".to_string()),
19440 view_revision: Some(revision.to_string()),
19441 control_revision: Some(revision.to_string()),
19442 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
19443 files: std::collections::BTreeMap::new(),
19444 scan_cache: std::collections::BTreeMap::new(),
19445 local_policy_digest: None,
19446 local_eligibility: std::collections::BTreeMap::new(),
19447 remote_copy_remains: std::collections::BTreeMap::new(),
19448 }
19449 }
19450
19451 #[test]
19452 fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
19453 let cfg = test_hub_config(
19454 "https://hub.example".to_string(),
19455 tempfile::tempdir().unwrap().keep(),
19456 );
19457 let mut baseline = scoped_test_baseline(&"a".repeat(64));
19458 baseline.assets.insert(
19459 "assets/archive.bin".to_string(),
19460 V2BaselineAsset {
19461 blob_sha256: "b".repeat(64),
19462 bytes: MAX_STORE_BYTES + 1,
19463 media_type: "application/octet-stream".to_string(),
19464 wrappers: vec!["records/archive.md".to_string()],
19465 required: true,
19466 disposition: "hosted".to_string(),
19467 leaf_hash: "c".repeat(64),
19468 },
19469 );
19470
19471 let accepted = serde_json::to_vec(&baseline).unwrap();
19472 assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
19473
19474 baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
19475 let refused = serde_json::to_vec(&baseline).unwrap();
19476 assert!(matches!(
19477 parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
19478 Err(LinkError::InvalidFeed { .. })
19479 ));
19480 }
19481
19482 #[test]
19483 fn v2_local_view_keeps_markdown_assets_in_content_but_excludes_binary_assets() {
19484 let directory = tempfile::tempdir().unwrap();
19485 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
19486 std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
19487 std::fs::write(
19488 directory.path().join("DB.md"),
19489 b"---\nname: Dual plane test\n---\n",
19490 )
19491 .unwrap();
19492 let markdown = b"---\ntype: note\n---\nintegrity tracked\n";
19493 let binary = b"pdf bytes";
19494 std::fs::write(directory.path().join("records/notes/a.md"), markdown).unwrap();
19495 std::fs::write(directory.path().join("sources/files/a.pdf"), binary).unwrap();
19496 let store = Store::open_strict(directory.path()).unwrap();
19497 crate::assets::write_manifest(
19498 &store,
19499 &[
19500 crate::AssetRecord {
19501 path: "records/notes/a.md".to_string(),
19502 sha256: content_sha256(markdown),
19503 bytes: markdown.len() as u64,
19504 media_type: "text/markdown".to_string(),
19505 wrappers: vec!["records/notes/a.md".to_string()],
19506 required: true,
19507 },
19508 crate::AssetRecord {
19509 path: "sources/files/a.pdf".to_string(),
19510 sha256: content_sha256(binary),
19511 bytes: binary.len() as u64,
19512 media_type: "application/pdf".to_string(),
19513 wrappers: vec!["records/notes/a.md".to_string()],
19514 required: true,
19515 },
19516 ],
19517 )
19518 .unwrap();
19519
19520 let view = v2_local_files(&store).unwrap();
19521 assert_eq!(
19522 view.riding.get("records/notes/a.md"),
19523 Some(&(content_sha256(markdown), markdown.len() as u64))
19524 );
19525 assert!(!view.riding.contains_key("sources/files/a.pdf"));
19526 }
19527
19528 #[test]
19529 fn v2_markdown_asset_binding_requires_identical_cross_root_state() {
19530 let path = "sources/notes/a.md".to_string();
19531 let hash = "a".repeat(64);
19532 let mut content = std::collections::BTreeMap::from([(
19533 path.clone(),
19534 V2BaselineFile {
19535 sha256: hash.clone(),
19536 bytes: 7,
19537 proof: None,
19538 },
19539 )]);
19540 let mut assets = std::collections::BTreeMap::from([(
19541 path.clone(),
19542 V2BaselineAsset {
19543 blob_sha256: hash,
19544 bytes: 7,
19545 media_type: "text/markdown".to_string(),
19546 wrappers: vec![path.clone()],
19547 required: true,
19548 disposition: "hosted".to_string(),
19549 leaf_hash: "b".repeat(64),
19550 },
19551 )]);
19552 assert!(verify_v2_markdown_asset_content_bindings(&content, &assets).is_ok());
19553
19554 content.get_mut(&path).unwrap().bytes = 8;
19555 assert!(verify_v2_markdown_asset_content_bindings(&content, &assets).is_err());
19556 content.remove(&path);
19557 assets.get_mut(&path).unwrap().disposition = "withheld".to_string();
19558 assert!(verify_v2_markdown_asset_content_bindings(&content, &assets).is_ok());
19559 content.insert(
19560 path,
19561 V2BaselineFile {
19562 sha256: "a".repeat(64),
19563 bytes: 7,
19564 proof: None,
19565 },
19566 );
19567 assert!(verify_v2_markdown_asset_content_bindings(&content, &assets).is_err());
19568 }
19569
19570 #[test]
19571 fn v2_markdown_asset_writes_use_the_typed_dual_plane_operation() {
19572 let path = "sources/notes/a.md";
19573 let assets = std::collections::BTreeMap::from([(
19574 path.to_string(),
19575 crate::AssetRecord {
19576 path: path.to_string(),
19577 sha256: "a".repeat(64),
19578 bytes: 7,
19579 media_type: "text/markdown".to_string(),
19580 wrappers: vec![path.to_string()],
19581 required: true,
19582 },
19583 )]);
19584 assert_eq!(
19585 v2_content_put_operation_kind(path, &assets),
19586 "put_asset_content"
19587 );
19588 assert_eq!(
19589 v2_content_put_operation_kind("records/ordinary.md", &assets),
19590 "put"
19591 );
19592 assert!(v2_withdrawal_includes_content(path, &assets));
19593 let mut binary_assets = assets.clone();
19594 binary_assets.insert(
19595 "sources/files/a.pdf".to_string(),
19596 crate::AssetRecord {
19597 path: "sources/files/a.pdf".to_string(),
19598 sha256: "d".repeat(64),
19599 bytes: 3,
19600 media_type: "application/pdf".to_string(),
19601 wrappers: vec![path.to_string()],
19602 required: true,
19603 },
19604 );
19605 assert!(!v2_withdrawal_includes_content(
19606 "sources/files/a.pdf",
19607 &binary_assets
19608 ));
19609
19610 let hash = "b".repeat(64);
19611 let operations = vec![json!({
19612 "op": "put_asset_content",
19613 "path": path,
19614 "expected": { "kind": "absent" },
19615 "blob": hash,
19616 "bytes": 11,
19617 })];
19618 let mut content = std::collections::BTreeMap::new();
19619 let mut remote_assets = std::collections::BTreeMap::new();
19620 apply_generated_v2_operations(&operations, &assets, &mut content, &mut remote_assets)
19621 .unwrap();
19622 assert_eq!(content.get(path).unwrap().sha256, "b".repeat(64));
19623 }
19624
19625 #[cfg(unix)]
19626 #[test]
19627 fn v2_stat_cache_waits_out_racy_files_and_binds_file_identity() {
19628 use std::os::unix::fs::MetadataExt as _;
19629
19630 let directory = tempfile::tempdir().unwrap();
19631 let first = directory.path().join("first.md");
19632 let second = directory.path().join("second.md");
19633 std::fs::write(&first, b"same").unwrap();
19634 std::fs::write(&second, b"same").unwrap();
19635 let first = std::fs::metadata(first).unwrap();
19636 let second = std::fs::metadata(second).unwrap();
19637 let observed_ns = |metadata: &std::fs::Metadata| {
19638 let mtime =
19639 i128::from(metadata.mtime()) * 1_000_000_000 + i128::from(metadata.mtime_nsec());
19640 let ctime =
19641 i128::from(metadata.ctime()) * 1_000_000_000 + i128::from(metadata.ctime_nsec());
19642 mtime.max(ctime)
19643 };
19644
19645 assert!(v2_scan_fingerprint_at(&first, observed_ns(&first) + 1_000_000_000).is_none());
19646 let first_fingerprint =
19647 v2_scan_fingerprint_at(&first, observed_ns(&first) + 3_000_000_000).unwrap();
19648 let second_fingerprint =
19649 v2_scan_fingerprint_at(&second, observed_ns(&second) + 3_000_000_000).unwrap();
19650 assert_ne!(first_fingerprint, second_fingerprint);
19651 }
19652
19653 #[test]
19654 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
19655 let directory = tempfile::tempdir().unwrap();
19656 std::fs::write(
19657 directory.path().join("DB.md"),
19658 scoped_projection_bytes(TEST_BRAIN_ID),
19659 )
19660 .unwrap();
19661 let store = Store::open_strict(directory.path()).unwrap();
19662 let head = scoped_test_head(&"a".repeat(64));
19663 let baseline = scoped_test_baseline(&"a".repeat(64));
19664 let mut view = v2_local_files(&store).unwrap();
19665 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
19666 assert!(!view.riding.contains_key("DB.md"));
19667 assert!(!view.eligibility.contains_key("DB.md"));
19668 }
19669
19670 #[test]
19671 fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
19672 let directory = tempfile::tempdir().unwrap();
19673 std::fs::write(
19674 directory.path().join("DB.md"),
19675 scoped_projection_bytes(TEST_BRAIN_ID),
19676 )
19677 .unwrap();
19678 let store = Store::open_strict(directory.path()).unwrap();
19679 let head = scoped_test_head(&"a".repeat(64));
19680 let baseline = scoped_test_baseline(&"a".repeat(64));
19681
19682 let mut carried = v2_local_files(&store).unwrap();
19683 remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
19684 let handed_off =
19685 local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
19686 assert!(!handed_off.riding.contains_key("DB.md"));
19687
19688 let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
19689 assert!(!freshly_scanned.riding.contains_key("DB.md"));
19690
19691 std::fs::write(
19692 directory.path().join("DB.md"),
19693 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
19694 )
19695 .unwrap();
19696 let tampered = Store::open_strict(directory.path()).unwrap();
19697 assert!(matches!(
19698 local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
19699 Err(LinkError::ScopedProjectionModified)
19700 ));
19701 }
19702
19703 #[test]
19704 fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
19705 let directory = tempfile::tempdir().unwrap();
19706 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
19707 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
19708 std::fs::write(
19709 directory.path().join("DB.md"),
19710 b"---\nname: Kept home test\n---\n",
19711 )
19712 .unwrap();
19713 std::fs::write(
19714 directory.path().join("records/notes/a.md"),
19715 b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
19716 )
19717 .unwrap();
19718 std::fs::write(
19719 directory.path().join("sources/private/secret.md"),
19720 b"---\ntype: note\n---\nlocal only\n",
19721 )
19722 .unwrap();
19723 std::fs::write(
19724 directory.path().join("sources/private/unlinked.md"),
19725 b"---\ntype: note\n---\nnot disclosed\n",
19726 )
19727 .unwrap();
19728 std::fs::write(
19729 directory.path().join(".sevralocal"),
19730 b"sources/private/**\n",
19731 )
19732 .unwrap();
19733
19734 let store = Store::open_strict(directory.path()).unwrap();
19735 let view = v2_local_files(&store).unwrap();
19736 assert!(!view.riding.contains_key("sources/private/secret.md"));
19737 assert_eq!(
19738 view.withheld_links,
19739 vec![V2WithheldLink {
19740 source: "records/notes/a.md".to_string(),
19741 target: "sources/private/secret.md".to_string(),
19742 }]
19743 );
19744 }
19745
19746 #[test]
19747 fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
19748 let directory = tempfile::tempdir().unwrap();
19753 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
19754 std::fs::write(
19755 directory.path().join("DB.md"),
19756 b"---\nname: Restored export\n---\n",
19757 )
19758 .unwrap();
19759 std::fs::write(
19760 directory.path().join("records/notes/a.md"),
19761 b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
19762 )
19763 .unwrap();
19764 std::fs::write(
19765 directory.path().join(".sevralocal"),
19766 b"sources/private/**\n",
19767 )
19768 .unwrap();
19769
19770 let store = Store::open_strict(directory.path()).unwrap();
19771 let view = v2_local_files(&store).unwrap();
19772 assert_eq!(
19773 view.withheld_links,
19774 vec![V2WithheldLink {
19775 source: "records/notes/a.md".to_string(),
19776 target: "sources/private/absent.md".to_string(),
19777 }]
19778 );
19779 std::fs::write(
19781 directory.path().join("records/notes/b.md"),
19782 b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
19783 )
19784 .unwrap();
19785 let store = Store::open_strict(directory.path()).unwrap();
19786 let view = v2_local_files(&store).unwrap();
19787 assert!(
19788 !view
19789 .withheld_links
19790 .iter()
19791 .any(|link| link.target == "records/notes/nowhere.md"),
19792 "an unclaimed dangling target must not be declared withheld"
19793 );
19794 }
19795
19796 #[test]
19797 fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
19798 let directory = tempfile::tempdir().unwrap();
19799 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
19800 std::fs::write(
19801 directory.path().join("DB.md"),
19802 b"---\nname: Withdrawal test\n---\n",
19803 )
19804 .unwrap();
19805 let source = b"---\ntype: note\n---\nlocal evidence\n";
19806 std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
19807 std::fs::write(
19808 directory.path().join(".sevralocal"),
19809 b"sources/private/**\n",
19810 )
19811 .unwrap();
19812 let store = Store::open_strict(directory.path()).unwrap();
19813 let view = v2_local_files(&store).unwrap();
19814 let mut remote = std::collections::BTreeMap::new();
19815 remote.insert(
19816 "sources/private/evidence.md".to_string(),
19817 V2BaselineFile {
19818 sha256: content_sha256(source),
19819 bytes: source.len() as u64,
19820 proof: None,
19821 },
19822 );
19823 assert_eq!(
19824 v2_content_withdrawal_operation(
19825 &store,
19826 &view,
19827 &remote,
19828 "sources/private/evidence.md",
19829 "approved retention change",
19830 )
19831 .unwrap(),
19832 json!({
19833 "op": "withdraw_from_hosting",
19834 "path": "sources/private/evidence.md",
19835 "expected": { "kind": "blob", "hash": content_sha256(source) },
19836 "reason": "approved retention change",
19837 })
19838 );
19839
19840 std::fs::write(
19841 directory.path().join("sources/private/evidence.md"),
19842 b"changed after review",
19843 )
19844 .unwrap();
19845 assert!(matches!(
19846 v2_content_withdrawal_operation(
19847 &store,
19848 &view,
19849 &remote,
19850 "sources/private/evidence.md",
19851 "approved retention change",
19852 ),
19853 Err(LinkError::InvalidPack { .. })
19854 ));
19855 }
19856
19857 #[test]
19858 fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
19859 let directory = tempfile::tempdir().unwrap();
19860 std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
19861 std::fs::write(
19862 directory.path().join("DB.md"),
19863 b"---\nname: Asset withdrawal test\n---\n",
19864 )
19865 .unwrap();
19866 let bytes = b"private binary";
19867 std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
19868 std::fs::write(
19869 directory.path().join(".sevralocal"),
19870 b"sources/files/private.pdf\n",
19871 )
19872 .unwrap();
19873 let store = Store::open_strict(directory.path()).unwrap();
19874 let view = v2_local_files(&store).unwrap();
19875 let local = crate::AssetRecord {
19876 path: "sources/files/private.pdf".to_string(),
19877 sha256: content_sha256(bytes),
19878 bytes: bytes.len() as u64,
19879 media_type: "application/pdf".to_string(),
19880 wrappers: vec![
19881 "sources/files/private.md".to_string(),
19882 "sources/redacted/private.md".to_string(),
19883 ],
19884 required: false,
19885 };
19886 let current = V2BaselineAsset {
19887 blob_sha256: local.sha256.clone(),
19888 bytes: local.bytes,
19889 media_type: local.media_type.clone(),
19890 wrappers: vec!["sources/files/private.md".to_string()],
19891 required: true,
19892 disposition: "hosted".to_string(),
19893 leaf_hash: "d".repeat(64),
19894 };
19895 assert_eq!(
19896 v2_asset_withdrawal_operation(
19897 &store,
19898 &view,
19899 &local.path,
19900 &local,
19901 ¤t,
19902 "approved retention change",
19903 )
19904 .unwrap(),
19905 json!({
19906 "op": "asset_withdraw",
19907 "path": local.path,
19908 "expected": { "kind": "asset", "hash": "d".repeat(64) },
19909 "asset": {
19910 "blob_sha256": local.sha256.clone(),
19911 "bytes": local.bytes,
19912 "media_type": local.media_type.clone(),
19913 "wrappers": local.wrappers.clone(),
19914 "required": false,
19915 "disposition": "withheld",
19916 },
19917 "reason": "approved retention change",
19918 })
19919 );
19920
19921 let mut mismatched = current.clone();
19922 mismatched.blob_sha256 = "f".repeat(64);
19923 assert!(matches!(
19924 v2_asset_withdrawal_operation(
19925 &store,
19926 &view,
19927 &local.path,
19928 &local,
19929 &mismatched,
19930 "approved retention change",
19931 ),
19932 Err(LinkError::InvalidPack { .. })
19933 ));
19934 }
19935
19936 #[test]
19937 fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
19938 let first = v2_checkout_id(None).unwrap();
19939 assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
19940 assert_ne!(first, v2_checkout_id(None).unwrap());
19941 assert!(is_sha256(&first));
19942 }
19943
19944 #[test]
19945 fn moved_checkout_relocates_its_path_bound_baseline_without_rehash_ambiguity() {
19946 let sandbox = tempfile::tempdir().unwrap();
19947 let stage_root = sandbox.path().join("stage");
19948 let live_root = sandbox.path().join("live");
19949 let from = stage_root.join("db");
19950 let to = live_root.join("db");
19951 std::fs::create_dir_all(from.join("records/items")).unwrap();
19952 std::fs::write(
19953 from.join("DB.md"),
19954 b"---\ntype: db-md\nscope: company\nowner: test\n---\n",
19955 )
19956 .unwrap();
19957 std::fs::write(
19958 from.join("records/items/example.md"),
19959 b"---\ntype: item\n---\n\n# Example\n",
19960 )
19961 .unwrap();
19962 let cfg = test_hub_config(
19963 "https://hub.example".to_string(),
19964 sandbox.path().join("state"),
19965 );
19966 let store = Store::open_strict(&from).unwrap();
19967 let local = v2_local_files(&store).unwrap();
19968 let files = local
19969 .riding
19970 .iter()
19971 .map(|(path, (sha256, bytes))| {
19972 (
19973 path.clone(),
19974 V2BaselineFile {
19975 sha256: sha256.clone(),
19976 bytes: *bytes,
19977 proof: None,
19978 },
19979 )
19980 })
19981 .collect();
19982 let baseline = V2SyncBaseline {
19983 v: 2,
19984 origin: "https://hub.example".to_string(),
19985 brain: TEST_BRAIN_ID.to_string(),
19986 checkout_id: Some("c".repeat(64)),
19987 head_seq: Some(7),
19988 commit_hash: Some("a".repeat(64)),
19989 content_root: Some("b".repeat(64)),
19990 asset_root: None,
19991 assets: std::collections::BTreeMap::new(),
19992 view_kind: Some("full".to_string()),
19993 view_revision: Some("d".repeat(64)),
19994 control_revision: Some("e".repeat(64)),
19995 projection_sha256: None,
19996 files,
19997 scan_cache: local.scan_cache.clone(),
19998 local_policy_digest: Some(local.policy.digest.clone()),
19999 local_eligibility: local.eligibility.clone(),
20000 remote_copy_remains: std::collections::BTreeMap::new(),
20001 };
20002 save_v2_baseline(&cfg, TEST_BRAIN_ID, &from, &baseline).unwrap();
20003 std::fs::rename(&stage_root, &live_root).unwrap();
20004
20005 let first = relocate_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from, &to).unwrap();
20006 assert_eq!(first.get("moved").and_then(Value::as_bool), Some(true));
20007 assert!(!has_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from).unwrap());
20008 assert!(has_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &to).unwrap());
20009
20010 let retry = relocate_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from, &to).unwrap();
20011 assert_eq!(retry.get("moved").and_then(Value::as_bool), Some(false));
20012 }
20013
20014 #[test]
20015 fn scoped_projection_edit_and_scope_transition_fail_closed() {
20016 let directory = tempfile::tempdir().unwrap();
20017 std::fs::write(
20018 directory.path().join("DB.md"),
20019 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
20020 )
20021 .unwrap();
20022 let store = Store::open_strict(directory.path()).unwrap();
20023 let head = scoped_test_head(&"a".repeat(64));
20024 let baseline = scoped_test_baseline(&"a".repeat(64));
20025 let mut view = v2_local_files(&store).unwrap();
20026 assert!(matches!(
20027 remove_scoped_projection(&head, Some(&baseline), &mut view),
20028 Err(LinkError::ScopedProjectionModified)
20029 ));
20030
20031 let changed = scoped_test_head(&"b".repeat(64));
20032 assert!(matches!(
20033 ensure_v2_view_compatible(&changed, Some(&baseline)),
20034 Err(LinkError::ScopedViewChanged)
20035 ));
20036
20037 let mut same_view_new_control = head.clone();
20038 same_view_new_control.control_revision = "c".repeat(64);
20039 assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
20040 assert!(!same_v2_head(&head, &same_view_new_control));
20041 }
20042
20043 #[test]
20044 fn verified_baseline_reuse_requires_exact_content_assets_view_and_authority() {
20045 let mut head = scoped_test_head(&"a".repeat(64));
20046 head.control_revision = "b".repeat(64);
20047 head.pointer = Some(V2PointerBody {
20048 v: 2,
20049 brain: TEST_BRAIN_ID.to_string(),
20050 seq: 7,
20051 commit_hash: "c".repeat(64),
20052 feed_hash: "d".repeat(64),
20053 content_root: Some("e".repeat(64)),
20054 asset_root: Some("f".repeat(64)),
20055 materializer: "dbmd-projection-v1".to_string(),
20056 signer_epoch: 1,
20057 control_revision: head.control_revision.clone(),
20058 backup_preparation: "0".repeat(64),
20059 prior_pointer_hash: Some("1".repeat(64)),
20060 signed_at: "2026-08-24T00:00:00.000Z".to_string(),
20061 });
20062 let mut baseline = scoped_test_baseline(&head.view_revision);
20063 baseline.head_seq = Some(7);
20064 baseline.commit_hash = Some("c".repeat(64));
20065 baseline.content_root = Some("e".repeat(64));
20066 baseline.asset_root = Some("f".repeat(64));
20067 baseline.control_revision = Some(head.control_revision.clone());
20068 assert!(v2_baseline_matches_head(&head, &baseline));
20069
20070 let mut changed = baseline.clone();
20071 changed.head_seq = Some(8);
20072 assert!(!v2_baseline_matches_head(&head, &changed));
20073 let mut changed = baseline.clone();
20074 changed.commit_hash = Some("2".repeat(64));
20075 assert!(!v2_baseline_matches_head(&head, &changed));
20076 let mut changed = baseline.clone();
20077 changed.content_root = Some("3".repeat(64));
20078 assert!(!v2_baseline_matches_head(&head, &changed));
20079 let mut changed = baseline.clone();
20080 changed.asset_root = Some("4".repeat(64));
20081 assert!(!v2_baseline_matches_head(&head, &changed));
20082 let mut changed = baseline.clone();
20083 changed.view_revision = Some("5".repeat(64));
20084 assert!(!v2_baseline_matches_head(&head, &changed));
20085 let mut changed = baseline.clone();
20086 changed.control_revision = Some("6".repeat(64));
20087 assert!(!v2_baseline_matches_head(&head, &changed));
20088
20089 let mut changed_head = head.clone();
20090 changed_head.view_kind = "full".to_string();
20091 assert!(!v2_baseline_matches_head(&changed_head, &baseline));
20092 }
20093
20094 #[test]
20095 fn legacy_baseline_without_authority_revision_refreshes_before_reuse() {
20096 let sandbox = tempfile::tempdir().unwrap();
20097 let cfg = test_hub_config(
20098 "https://hub.example".to_string(),
20099 sandbox.path().to_path_buf(),
20100 );
20101 let head = scoped_test_head(&"a".repeat(64));
20102 let baseline = scoped_test_baseline(&head.view_revision);
20103 let mut encoded = serde_json::to_value(&baseline).unwrap();
20104 encoded.as_object_mut().unwrap().remove("control_revision");
20105 let parsed =
20106 parse_v2_baseline(&cfg, TEST_BRAIN_ID, &serde_json::to_vec(&encoded).unwrap()).unwrap();
20107 assert!(parsed.control_revision.is_none());
20108 assert!(!v2_baseline_matches_head(&head, &parsed));
20109 }
20110
20111 #[test]
20112 fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
20113 let scoped = scoped_test_head(&"a".repeat(64));
20114 let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
20115 assert!(matches!(
20116 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
20117 Err(LinkError::ScopedProjectionModified)
20118 ));
20119
20120 let mut full = scoped.clone();
20121 full.view_kind = "full".to_string();
20122 let mut full_baseline = scoped_baseline.clone();
20123 full_baseline.view_kind = Some("full".to_string());
20124 full_baseline.projection_sha256 = None;
20125 assert!(matches!(
20126 ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
20127 Err(LinkError::InvalidPack { .. })
20128 ));
20129
20130 assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
20131 assert!(
20132 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
20133 );
20134 }
20135
20136 #[test]
20137 fn scoped_view_metadata_is_explicitly_non_authoritative() {
20138 let head = scoped_test_head(&"a".repeat(64));
20139 let value: Value =
20140 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
20141 assert_eq!(value["kind"], "link.md-scoped-view");
20142 assert_eq!(value["authoritative"], false);
20143 assert_eq!(value["visible_files"], 7);
20144 assert_eq!(value["brain"], TEST_BRAIN_ID);
20145 }
20146
20147 #[test]
20148 fn local_scoped_marker_requires_the_exact_generated_projection() {
20149 let directory = tempfile::tempdir().unwrap();
20150 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
20151 std::fs::write(
20152 directory.path().join("DB.md"),
20153 scoped_projection_bytes(TEST_BRAIN_ID),
20154 )
20155 .unwrap();
20156 let head = scoped_test_head(&"a".repeat(64));
20157 std::fs::write(
20158 directory.path().join(".dbmd/view.json"),
20159 scoped_view_metadata(&head, 0).unwrap(),
20160 )
20161 .unwrap();
20162 let store = Store::open_strict(directory.path()).unwrap();
20163 assert!(has_verified_local_scoped_view(&store));
20164
20165 std::fs::write(
20166 directory.path().join("DB.md"),
20167 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
20168 )
20169 .unwrap();
20170 let altered = Store::open_strict(directory.path()).unwrap();
20171 assert!(!has_verified_local_scoped_view(&altered));
20172 }
20173
20174 fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
20175 use ring::signature::KeyPair as _;
20176
20177 let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
20178 let rng = ring::rand::SystemRandom::new();
20179 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
20180 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
20181 let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
20182 let public_key = URL_SAFE_NO_PAD.encode(&public_der);
20183 let fingerprint = format!("{:x}", Sha256::digest(&public_der));
20184 let blob = b"new";
20185 let blob_hash = content_sha256(blob);
20186 let changes = json!({
20187 "mutation_id": "sync:proposal-fixture",
20188 "operations": [{
20189 "blob": blob_hash,
20190 "bytes": blob.len(),
20191 "expected": null,
20192 "op": "put",
20193 "path": "records/new.md",
20194 }],
20195 "reason": "fixture",
20196 "v": 2,
20197 });
20198 let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
20199 let changes_base64 = STANDARD.encode(&changes_bytes);
20200 let descriptor = json!({
20201 "base": null,
20202 "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
20203 "changes_base64": changes_base64,
20204 "rebase": "strict",
20205 "v": 2,
20206 });
20207 let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
20208 let payload_hash = "b".repeat(64);
20209 let submitted_at = "2026-08-19T12:00:00.000Z";
20210 let claim = json!({
20211 "actor_root": {
20212 "actor_class": "foreign_key",
20213 "credential": "ed25519:fixture",
20214 "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
20215 "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
20216 "principal": "key:fixture",
20217 "role": null,
20218 },
20219 "brain": TEST_BRAIN_ID,
20220 "clear_sha256": clear_hash,
20221 "control_revision": "c".repeat(64),
20222 "mutation_id": "sync:proposal-fixture",
20223 "payload_sha256": payload_hash,
20224 "proposal_id": proposal_id,
20225 "submitted_at": submitted_at,
20226 "v": 2,
20227 });
20228 let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
20229 let envelope = json!({
20230 "claim": claim,
20231 "fingerprint": fingerprint,
20232 "public_key": public_key,
20233 "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
20234 });
20235 let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
20236 let submission_hash =
20237 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
20238 let mut head = scoped_test_head(&"c".repeat(64));
20239 head.view_kind = "full".to_string();
20240 head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
20241 let value = json!({
20242 "proposal": {
20243 "base": null,
20244 "blobs": [{
20245 "bytes": blob.len(),
20246 "endpoint": format!(
20247 "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
20248 ),
20249 "sha256": blob_hash,
20250 }],
20251 "changes_base64": changes_base64,
20252 "clear_sha256": clear_hash,
20253 "expires_at": "2026-08-26T12:00:00.000Z",
20254 "id": proposal_id,
20255 "payload_sha256": payload_hash,
20256 "proposer": { "class": "foreign_key" },
20257 "rebase": "strict",
20258 "state": "pending",
20259 "submission_claim_base64": STANDARD.encode(envelope_bytes),
20260 "submission_claim_sha256": submission_hash,
20261 "submitted_at": submitted_at,
20262 },
20263 "v": 2,
20264 });
20265 (head, proposal_id, value)
20266 }
20267
20268 #[test]
20269 fn v2_proposal_verifier_accepts_exact_signed_payload() {
20270 let (head, proposal_id, value) = signed_proposal_fixture();
20271 let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
20272 assert_eq!(verified.blobs.len(), 1);
20273 assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
20274 }
20275
20276 #[test]
20277 fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
20278 let (head, proposal_id, value) = signed_proposal_fixture();
20279
20280 let mut changed = value.clone();
20281 changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
20282 assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
20283
20284 let mut redirected = value.clone();
20285 redirected["proposal"]["blobs"][0]["endpoint"] =
20286 Value::String("https://attacker.example/blob".to_string());
20287 assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
20288
20289 let mut forged = value;
20290 let encoded = forged["proposal"]["submission_claim_base64"]
20291 .as_str()
20292 .unwrap();
20293 let mut envelope: Value =
20294 serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
20295 envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
20296 let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
20297 forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
20298 forged["proposal"]["submission_claim_sha256"] = Value::String(
20299 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
20300 );
20301 assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
20302 }
20303
20304 #[cfg(unix)]
20305 #[test]
20306 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
20307 let sandbox = tempfile::tempdir().unwrap();
20308 let destination = sandbox.path().join("brain");
20309 let entries = vec![
20310 (
20311 "DB.md".to_string(),
20312 scoped_projection_bytes(TEST_BRAIN_ID),
20313 ),
20314 (
20315 "records/contacts/a.md".to_string(),
20316 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
20317 .to_vec(),
20318 ),
20319 ];
20320 install_pulled_delta(&destination, &entries, &[], true).unwrap();
20321 assert!(destination.join("index.md").is_file());
20322 assert!(destination.join("records/index.md").is_file());
20323 assert!(destination.join("records/contacts/index.md").is_file());
20324 assert!(destination.join("records/contacts/index.jsonl").is_file());
20325 }
20326
20327 #[cfg(unix)]
20328 #[test]
20329 fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
20330 let sandbox = tempfile::tempdir().unwrap();
20331 let destination = sandbox.path().join("brain");
20332 let cache = sandbox.path().join("cache");
20333 std::fs::create_dir(&cache).unwrap();
20334 let db = scoped_projection_bytes(TEST_BRAIN_ID);
20335 let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
20336 let db_source = cache.join("db");
20337 let shared_source = cache.join("shared");
20338 crate::fsx::write_atomic(&db_source, &db).unwrap();
20339 crate::fsx::write_atomic(&shared_source, shared).unwrap();
20340 let mut entries = vec![V2StagedFile {
20341 path: "DB.md".to_string(),
20342 source: db_source,
20343 sha256: content_sha256(&db),
20344 bytes: db.len() as u64,
20345 }];
20346 for index in 0..512 {
20347 entries.push(V2StagedFile {
20348 path: format!("records/items/{index:05}.md"),
20349 source: shared_source.clone(),
20350 sha256: content_sha256(shared),
20351 bytes: shared.len() as u64,
20352 });
20353 }
20354 install_pulled_delta_sources(
20355 &destination,
20356 &entries,
20357 &[],
20358 false,
20359 None,
20360 &scoped_test_head(&"c".repeat(64)),
20361 )
20362 .unwrap();
20363 assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
20364 for index in 0..512 {
20365 assert_eq!(
20366 std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
20367 shared
20368 );
20369 }
20370 assert!(
20371 std::fs::read_dir(sandbox.path())
20372 .unwrap()
20373 .all(|entry| !entry
20374 .unwrap()
20375 .file_name()
20376 .to_string_lossy()
20377 .contains("pull-stage")),
20378 "the private stage must be atomically installed or removed"
20379 );
20380 }
20381
20382 #[cfg(unix)]
20383 #[test]
20384 fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
20385 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
20386
20387 let sandbox = tempfile::tempdir().unwrap();
20388 let root = sandbox.path().join("brain");
20389 std::fs::create_dir_all(root.join("records/items")).unwrap();
20390 let db = scoped_projection_bytes(TEST_BRAIN_ID);
20391 let old = b"---\ntype: note\n---\n\nold\n";
20392 let new = b"---\ntype: note\n---\n\nnew\n";
20393 let removed = b"---\ntype: note\n---\n\nremove me\n";
20394 std::fs::write(root.join("DB.md"), &db).unwrap();
20395 std::fs::write(root.join("records/items/change.md"), old).unwrap();
20396 std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
20397 for index in 0..512 {
20398 std::fs::write(
20399 root.join(format!("records/items/untouched-{index:04}.md")),
20400 old,
20401 )
20402 .unwrap();
20403 }
20404 let untouched = root.join("records/items/untouched-0256.md");
20405 let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
20406 let source = sandbox.path().join("changed-source");
20407 crate::fsx::write_atomic(&source, new).unwrap();
20408 let same_source = sandbox.path().join("unchanged-source");
20409 crate::fsx::write_atomic(&same_source, old).unwrap();
20410 let same_entry = V2StagedFile {
20411 path: "records/items/change.md".to_string(),
20412 source: same_source,
20413 sha256: content_sha256(old),
20414 bytes: old.len() as u64,
20415 };
20416 let entry = V2StagedFile {
20417 path: "records/items/change.md".to_string(),
20418 source,
20419 sha256: content_sha256(new),
20420 bytes: new.len() as u64,
20421 };
20422 let head = scoped_test_head(&"c".repeat(64));
20423
20424 install_established_v2_delta(
20428 Store::open_strict(&root).unwrap(),
20429 &[same_entry],
20430 &["records/items/already-absent.md".to_string()],
20431 true,
20432 None,
20433 &head,
20434 )
20435 .unwrap();
20436 assert_eq!(
20437 std::fs::metadata(&untouched).unwrap().ino(),
20438 untouched_inode
20439 );
20440 assert!(!root.join(V2_PULL_JOURNAL).exists());
20441
20442 install_established_v2_delta(
20443 Store::open_strict(&root).unwrap(),
20444 &[entry],
20445 &["records/items/delete.md".to_string()],
20446 false,
20447 None,
20448 &head,
20449 )
20450 .unwrap();
20451 assert_eq!(
20452 std::fs::read(root.join("records/items/change.md")).unwrap(),
20453 new
20454 );
20455 assert!(!root.join("records/items/delete.md").exists());
20456 assert_eq!(
20457 std::fs::metadata(&untouched).unwrap().ino(),
20458 untouched_inode
20459 );
20460 assert!(root.join(V2_PULL_JOURNAL).is_file());
20461 assert_eq!(
20462 std::fs::metadata(root.join(V2_PULL_JOURNAL))
20463 .unwrap()
20464 .permissions()
20465 .mode()
20466 & 0o777,
20467 0o600
20468 );
20469 let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
20470 .unwrap()
20471 .unwrap();
20472 assert_eq!(
20473 std::fs::metadata(root.join(&journal.backup_dir))
20474 .unwrap()
20475 .permissions()
20476 .mode()
20477 & 0o777,
20478 0o700
20479 );
20480 for entry in &journal.entries {
20481 if let Some(backup) = &entry.backup {
20482 assert_eq!(
20483 std::fs::metadata(root.join(&journal.backup_dir).join(backup))
20484 .unwrap()
20485 .permissions()
20486 .mode()
20487 & 0o777,
20488 0o600
20489 );
20490 }
20491 }
20492
20493 let cfg = test_hub_config(
20494 "https://example.test".to_string(),
20495 sandbox.path().join("state"),
20496 );
20497 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20498 assert_eq!(
20499 std::fs::read(root.join("records/items/change.md")).unwrap(),
20500 old
20501 );
20502 assert_eq!(
20503 std::fs::read(root.join("records/items/delete.md")).unwrap(),
20504 removed
20505 );
20506 assert_eq!(
20507 std::fs::metadata(&untouched).unwrap().ino(),
20508 untouched_inode
20509 );
20510 assert!(!root.join(V2_PULL_JOURNAL).exists());
20511 }
20512
20513 #[test]
20514 fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
20515 let body = b"bounded bytes";
20516 let path = "records/example.md".to_string();
20517 let file = V2BaselineFile {
20518 sha256: content_sha256(body),
20519 bytes: body.len() as u64,
20520 proof: None,
20521 };
20522 let header = serde_json::to_vec(&json!({
20523 "bytes": body.len(),
20524 "path": path,
20525 "sha256": file.sha256,
20526 "v": 2,
20527 }))
20528 .unwrap();
20529 let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
20530 stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
20531 stream.extend_from_slice(&header);
20532 stream.extend_from_slice(body);
20533 stream.extend_from_slice(&0_u32.to_be_bytes());
20534 let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
20535 assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
20536
20537 let mut tampered = stream.clone();
20538 let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
20539 tampered[body_offset] ^= 1;
20540 assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
20541
20542 let mut trailing = stream;
20543 trailing.push(0);
20544 assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
20545 }
20546
20547 #[test]
20548 fn first_checkout_resolution_does_not_recreate_the_same_conflict() {
20549 let path = "records/value.md".to_string();
20550 let mut local = std::collections::BTreeMap::new();
20551 local.insert(path.clone(), (content_sha256(b"local"), 5));
20552 let mut remote = std::collections::BTreeMap::new();
20553 remote.insert(
20554 path.clone(),
20555 V2BaselineFile {
20556 sha256: content_sha256(b"remote"),
20557 bytes: 6,
20558 proof: None,
20559 },
20560 );
20561
20562 assert_eq!(
20563 v2_initial_content_conflicts(&local, &remote, false),
20564 vec![path]
20565 );
20566 assert!(v2_initial_content_conflicts(&local, &remote, true).is_empty());
20567
20568 let mut resolution = std::collections::BTreeMap::new();
20569 resolution.insert(
20570 "records/value.md".to_string(),
20571 V2ResolutionOverride {
20572 expected_remote: Some(content_sha256(b"remote")),
20573 selected_local: Some(content_sha256(b"local")),
20574 },
20575 );
20576 assert!(v2_resolution_allows_path(
20577 Some(&resolution),
20578 "records/value.md",
20579 true
20580 ));
20581 assert!(v2_resolution_allows_path(
20582 Some(&resolution),
20583 "records/new-target.md",
20584 false
20585 ));
20586 assert!(!v2_resolution_allows_path(
20587 Some(&resolution),
20588 "records/unreviewed-remote.md",
20589 true
20590 ));
20591 }
20592
20593 #[test]
20594 fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
20595 let sandbox = tempfile::TempDir::new().unwrap();
20596 let root = sandbox.path().join("brain");
20597 std::fs::create_dir_all(&root).unwrap();
20598 std::fs::write(
20599 root.join("DB.md"),
20600 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20601 )
20602 .unwrap();
20603 let store = Store::open_strict(&root).unwrap();
20604 let incomplete = crate::ulid::mint();
20605 store
20606 .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
20607 .unwrap();
20608 let expired = crate::ulid::mint();
20609 store
20610 .create_dir_all(&v2_conflict_relative(&expired, "files"))
20611 .unwrap();
20612 let plan = V2ConflictPlan {
20613 v: 2,
20614 class: "content_resolution_required".to_string(),
20615 bundle: expired.clone(),
20616 brain: TEST_BRAIN_ID.to_string(),
20617 origin: "https://example.test".to_string(),
20618 created_unix: 0,
20619 expires_unix: 0,
20620 base_seq: None,
20621 base_commit: None,
20622 remote_seq: 0,
20623 remote_commit: None,
20624 remote_content_root: None,
20625 view_kind: "full".to_string(),
20626 view_revision: "a".repeat(64),
20627 files: vec![V2ConflictFile {
20628 path: "records/value.md".to_string(),
20629 base: V2ConflictCoordinate {
20630 sha256: None,
20631 bytes: None,
20632 file: None,
20633 },
20634 local: V2ConflictCoordinate {
20635 sha256: None,
20636 bytes: None,
20637 file: None,
20638 },
20639 remote: V2ConflictCoordinate {
20640 sha256: None,
20641 bytes: None,
20642 file: None,
20643 },
20644 }],
20645 };
20646 let mut bytes = serde_json::to_vec(&plan).unwrap();
20647 bytes.push(b'\n');
20648 store
20649 .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
20650 .unwrap();
20651
20652 let listed = sync_conflicts(&root, false, false).unwrap();
20653 assert_eq!(listed["bundles"], 2);
20654 assert_eq!(listed["pruned"], 0);
20655 let pruned = sync_conflicts(&root, true, false).unwrap();
20656 assert_eq!(pruned["bundles"], 0);
20657 assert_eq!(pruned["pruned"], 2);
20658 assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
20659 assert!(!root.join(".dbmd/conflicts").join(expired).exists());
20660 }
20661
20662 #[test]
20663 fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
20664 let sandbox = tempfile::TempDir::new().unwrap();
20665 let root = sandbox.path().join("brain");
20666 std::fs::create_dir_all(&root).unwrap();
20667 std::fs::write(
20668 root.join("DB.md"),
20669 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20670 )
20671 .unwrap();
20672 let store = Store::open_strict(&root).unwrap();
20673 let bundle = crate::ulid::mint();
20674 store
20675 .create_dir_all(&v2_conflict_relative(&bundle, "files"))
20676 .unwrap();
20677 store
20678 .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
20679 .unwrap();
20680
20681 assert!(sync_conflicts(&root, true, false).is_err());
20682 assert!(sync_conflicts(&root, false, true).is_err());
20683 let pruned = sync_conflicts(&root, true, true).unwrap();
20684 assert_eq!(pruned["pruned"], 1);
20685 assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
20686 }
20687
20688 #[test]
20689 fn pull_journal_admits_large_asset_coordinates_but_keeps_a_hard_bound() {
20690 let coordinate = |bytes| V2PullJournal {
20691 v: 1,
20692 phase: V2PullPhase::Preparing,
20693 brain: TEST_BRAIN_ID.to_string(),
20694 previous: V2PullCoordinate {
20695 head_seq: None,
20696 commit_hash: None,
20697 view_kind: None,
20698 view_revision: None,
20699 },
20700 next: V2PullCoordinate {
20701 head_seq: Some(1),
20702 commit_hash: Some("a".repeat(64)),
20703 view_kind: Some("full".to_string()),
20704 view_revision: Some("b".repeat(64)),
20705 },
20706 backup_dir: format!(".dbmd/pull-backup-{}", crate::ulid::mint()),
20707 entries: vec![V2PullJournalEntry {
20708 path: "sources/media/large.mov".to_string(),
20709 old: None,
20710 new: Some(V2PullFileCoordinate {
20711 sha256: "c".repeat(64),
20712 bytes,
20713 }),
20714 backup: None,
20715 }],
20716 };
20717
20718 validate_v2_pull_journal(&coordinate(MAX_STORE_BYTES + 1)).unwrap();
20719 assert!(validate_v2_pull_journal(&coordinate(MAX_PULL_TRANSACTION_BYTES + 1)).is_err());
20720 }
20721
20722 #[test]
20723 fn ready_pull_journal_rolls_back_exact_preimages() {
20724 let sandbox = tempfile::TempDir::new().unwrap();
20725 let root = sandbox.path().join("brain");
20726 std::fs::create_dir_all(root.join("records")).unwrap();
20727 std::fs::write(
20728 root.join("DB.md"),
20729 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20730 )
20731 .unwrap();
20732 let path = "records/value.md";
20733 let old = b"---\ntype: note\n---\n\nold\n";
20734 let new = b"---\ntype: note\n---\n\nnew\n";
20735 std::fs::write(root.join(path), old).unwrap();
20736 let store = Store::open_strict(&root).unwrap();
20737 let bundle = crate::ulid::mint();
20738 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20739 store
20740 .create_private_dir_all(Path::new(&backup_dir))
20741 .unwrap();
20742 store
20743 .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
20744 .unwrap();
20745 let journal = V2PullJournal {
20746 v: 1,
20747 phase: V2PullPhase::Ready,
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(2),
20757 commit_hash: Some("c".repeat(64)),
20758 view_kind: Some("full".to_string()),
20759 view_revision: Some("d".repeat(64)),
20760 },
20761 backup_dir: backup_dir.clone(),
20762 entries: vec![V2PullJournalEntry {
20763 path: path.to_string(),
20764 old: Some(V2PullFileCoordinate {
20765 sha256: content_sha256(old),
20766 bytes: old.len() as u64,
20767 }),
20768 new: Some(V2PullFileCoordinate {
20769 sha256: content_sha256(new),
20770 bytes: new.len() as u64,
20771 }),
20772 backup: Some("00000000".to_string()),
20773 }],
20774 };
20775 validate_v2_pull_journal(&journal).unwrap();
20776 store
20777 .write_private_atomic_new(
20778 Path::new(V2_PULL_JOURNAL),
20779 &v2_pull_journal_bytes(&journal).unwrap(),
20780 )
20781 .unwrap();
20782 store.write_atomic(Path::new(path), new).unwrap();
20783
20784 let cfg = test_hub_config(
20785 "https://example.test".to_string(),
20786 sandbox.path().join("state"),
20787 );
20788 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20789 assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
20790 assert!(!root.join(V2_PULL_JOURNAL).exists());
20791 assert!(!root.join(backup_dir).exists());
20792 }
20793
20794 #[test]
20795 fn preparing_pull_journal_discards_only_private_staging() {
20796 let sandbox = tempfile::TempDir::new().unwrap();
20797 let root = sandbox.path().join("brain");
20798 std::fs::create_dir_all(root.join(".dbmd")).unwrap();
20799 std::fs::write(
20800 root.join("DB.md"),
20801 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20802 )
20803 .unwrap();
20804 let store = Store::open_strict(&root).unwrap();
20805 let bundle = crate::ulid::mint();
20806 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20807 store
20808 .create_private_dir_all(Path::new(&backup_dir))
20809 .unwrap();
20810 let journal = V2PullJournal {
20811 v: 1,
20812 phase: V2PullPhase::Preparing,
20813 brain: TEST_BRAIN_ID.to_string(),
20814 previous: V2PullCoordinate {
20815 head_seq: None,
20816 commit_hash: None,
20817 view_kind: None,
20818 view_revision: None,
20819 },
20820 next: 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 backup_dir: backup_dir.clone(),
20827 entries: vec![V2PullJournalEntry {
20828 path: "records/new.md".to_string(),
20829 old: None,
20830 new: Some(V2PullFileCoordinate {
20831 sha256: "c".repeat(64),
20832 bytes: 1,
20833 }),
20834 backup: None,
20835 }],
20836 };
20837 store
20838 .write_private_atomic_new(
20839 Path::new(V2_PULL_JOURNAL),
20840 &v2_pull_journal_bytes(&journal).unwrap(),
20841 )
20842 .unwrap();
20843 let cfg = test_hub_config(
20844 "https://example.test".to_string(),
20845 sandbox.path().join("state"),
20846 );
20847
20848 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20849
20850 assert!(root.join("DB.md").is_file());
20851 assert!(!root.join(V2_PULL_JOURNAL).exists());
20852 assert!(!root.join(backup_dir).exists());
20853 }
20854
20855 #[test]
20856 fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
20857 let sandbox = tempfile::TempDir::new().unwrap();
20858 let root = sandbox.path().join("brain");
20859 std::fs::create_dir_all(root.join("records")).unwrap();
20860 std::fs::write(
20861 root.join("DB.md"),
20862 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20863 )
20864 .unwrap();
20865 let new = b"---\ntype: note\n---\n\nnew\n";
20866 std::fs::write(root.join("records/value.md"), new).unwrap();
20867 let store = Store::open_strict(&root).unwrap();
20868 let bundle = crate::ulid::mint();
20869 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20870 store
20871 .create_private_dir_all(Path::new(&backup_dir))
20872 .unwrap();
20873 let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
20874 store.create_private_dir_all(Path::new(&orphan)).unwrap();
20875 let next = V2PullCoordinate {
20876 head_seq: Some(2),
20877 commit_hash: Some("c".repeat(64)),
20878 view_kind: Some("full".to_string()),
20879 view_revision: Some("d".repeat(64)),
20880 };
20881 let journal = V2PullJournal {
20882 v: 1,
20883 phase: V2PullPhase::Ready,
20884 brain: TEST_BRAIN_ID.to_string(),
20885 previous: V2PullCoordinate {
20886 head_seq: Some(1),
20887 commit_hash: Some("a".repeat(64)),
20888 view_kind: Some("full".to_string()),
20889 view_revision: Some("b".repeat(64)),
20890 },
20891 next: next.clone(),
20892 backup_dir: backup_dir.clone(),
20893 entries: vec![V2PullJournalEntry {
20894 path: "records/value.md".to_string(),
20895 old: Some(V2PullFileCoordinate {
20896 sha256: "e".repeat(64),
20897 bytes: new.len() as u64,
20898 }),
20899 new: Some(V2PullFileCoordinate {
20900 sha256: content_sha256(new),
20901 bytes: new.len() as u64,
20902 }),
20903 backup: Some("00000000".to_string()),
20904 }],
20905 };
20906 store
20907 .write_private_atomic_new(
20908 Path::new(V2_PULL_JOURNAL),
20909 &v2_pull_journal_bytes(&journal).unwrap(),
20910 )
20911 .unwrap();
20912 let cfg = test_hub_config(
20913 "https://example.test".to_string(),
20914 sandbox.path().join("state"),
20915 );
20916 save_v2_baseline(
20917 &cfg,
20918 TEST_BRAIN_ID,
20919 &root,
20920 &V2SyncBaseline {
20921 v: 2,
20922 origin: "https://example.test".to_string(),
20923 brain: TEST_BRAIN_ID.to_string(),
20924 checkout_id: Some("c".repeat(64)),
20925 head_seq: next.head_seq,
20926 commit_hash: next.commit_hash.clone(),
20927 content_root: Some("f".repeat(64)),
20928 asset_root: None,
20929 assets: Default::default(),
20930 view_kind: next.view_kind.clone(),
20931 view_revision: next.view_revision.clone(),
20932 control_revision: Some("d".repeat(64)),
20933 projection_sha256: None,
20934 files: Default::default(),
20935 scan_cache: Default::default(),
20936 local_policy_digest: None,
20937 local_eligibility: Default::default(),
20938 remote_copy_remains: Default::default(),
20939 },
20940 )
20941 .unwrap();
20942
20943 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20944
20945 assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
20946 assert!(!root.join(V2_PULL_JOURNAL).exists());
20947 assert!(!root.join(backup_dir).exists());
20948 assert!(!root.join(orphan).exists());
20949 }
20950
20951 #[test]
20952 fn only_typed_validation_projection_lag_retries_v2_head() {
20953 let typed = HubResponse {
20954 status: 422,
20955 body: Some(json!({ "code": "validation_index_catching_up" })),
20956 };
20957 let nested = HubResponse {
20958 status: 422,
20959 body: Some(json!({
20960 "details": { "code": "validation_index_catching_up" }
20961 })),
20962 };
20963 let unrelated = HubResponse {
20964 status: 422,
20965 body: Some(json!({ "code": "source_immutable" })),
20966 };
20967 let wrong_status = HubResponse {
20968 status: 403,
20969 body: typed.body.clone(),
20970 };
20971 assert!(v2_validation_catching_up(&typed));
20972 assert!(v2_validation_catching_up(&nested));
20973 assert!(!v2_validation_catching_up(&unrelated));
20974 assert!(!v2_validation_catching_up(&wrong_status));
20975 }
20976
20977 #[test]
20978 fn asset_resolution_is_limited_to_explicitly_resolved_wrappers() {
20979 let wrapper = "records/operational/package.md".to_string();
20980 let mut resolution = std::collections::BTreeMap::new();
20981 resolution.insert(
20982 wrapper.clone(),
20983 V2ResolutionOverride {
20984 expected_remote: Some("a".repeat(64)),
20985 selected_local: Some("b".repeat(64)),
20986 },
20987 );
20988 let local = crate::AssetRecord {
20989 path: "sources/package/object.blob".to_string(),
20990 sha256: "c".repeat(64),
20991 bytes: 1,
20992 media_type: "application/octet-stream".to_string(),
20993 wrappers: vec![wrapper.clone()],
20994 required: true,
20995 };
20996 assert!(v2_resolution_allows_asset(
20997 Some(&resolution),
20998 None,
20999 None,
21000 Some(&local),
21001 ));
21002 let unrelated = crate::AssetRecord {
21003 wrappers: vec!["records/unrelated.md".to_string()],
21004 ..local
21005 };
21006 assert!(!v2_resolution_allows_asset(
21007 Some(&resolution),
21008 None,
21009 None,
21010 Some(&unrelated),
21011 ));
21012 assert!(!v2_resolution_allows_asset(
21013 None,
21014 None,
21015 None,
21016 Some(&unrelated),
21017 ));
21018 }
21019}