1use std::collections::{BTreeMap, BTreeSet};
61use std::io::{Cursor, Read, Write};
62use std::path::{Path, PathBuf};
63use std::time::{SystemTime, UNIX_EPOCH};
64
65use base64::{
66 engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
67 Engine as _,
68};
69use ring::signature::{UnparsedPublicKey, ED25519};
70use serde::{Deserialize, Serialize};
71use serde_json::{json, Value};
72use sha2::{Digest, Sha256};
73
74use crate::store::Store;
75
76pub const HUB_URL_ENV: &str = "DBMD_HUB_URL";
78
79pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
82
83pub const HUB_CREDENTIAL_ORIGIN_ENV: &str = "DBMD_HUB_CREDENTIAL_ORIGIN";
87
88pub const STATE_DIR_ENV: &str = "DBMD_STATE_DIR";
92
93pub const ALLOW_PRIVATE_REGISTRY_HOME_ENV: &str = "DBMD_ALLOW_PRIVATE_REGISTRY_HOME";
97
98pub const ALLOW_PRIVATE_OBJECT_URL_ENV: &str = "DBMD_ALLOW_PRIVATE_OBJECT_URL";
102
103pub const BRAIN_KEY_FILE_ENV: &str = "DBMD_BRAIN_KEY_FILE";
109
110pub const AGENT_KEY_FILE_ENV: &str = "DBMD_AGENT_KEY_FILE";
118
119pub const CONFIG_REL_PATH: &str = ".dbmd/config";
122
123const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024;
126const MAX_FEED_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
129const MAX_REGISTRY_CARD_BYTES: u64 = 1024 * 1024;
131
132const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
135const MAX_STAGED_CHANGE_BYTES: usize = 64 * 1024 * 1024;
138
139const MAX_PUSH_FILES: usize = u16::MAX as usize;
141const MAX_STORE_PATH_BYTES: usize = 1_024;
142const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
143const MAX_ASSET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
148const MAX_PACK_BYTES: u64 =
151 MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
152const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
161const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
162
163const MAX_IDENTITY_ROTATIONS: usize = 1_024;
166
167fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
170 let mut batches: Vec<Vec<Value>> = Vec::new();
171 let mut current: Vec<Value> = Vec::new();
172 let mut current_bytes = 0usize;
173 for declaration in declarations {
174 let declared_bytes = serde_json::to_string(&declaration)
175 .map(|text| text.len())
176 .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
177 + 1;
178 if !current.is_empty()
179 && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
180 || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
181 {
182 batches.push(std::mem::take(&mut current));
183 current_bytes = 0;
184 }
185 current_bytes += declared_bytes;
186 current.push(declaration);
187 }
188 if !current.is_empty() {
189 batches.push(current);
190 }
191 batches
192}
193const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
197const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
198const FEED_PAGE_LIMIT: usize = 100;
199
200pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
205
206const CONNECT_TIMEOUT_SECS: u64 = 10;
209const READ_TIMEOUT_SECS: u64 = 120;
210const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
214const COMMIT_REQUEST_TIMEOUT_SECS: u64 = 900;
221const COMMIT_ATTEMPTS: usize = 4;
225const COMMIT_RETRY_BACKOFF_MS: [u64; COMMIT_ATTEMPTS - 1] = [5_000, 20_000, 45_000];
226const CONNECT_ATTEMPTS: usize = 3;
227const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
228
229const UPLOAD_ATTEMPTS: usize = 6;
233const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
234const UPLOAD_TOTAL_TIMEOUT_SECS: u64 = 300;
238
239fn upload_retry_backoff_ms(attempt: usize) -> u64 {
240 UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
241}
242
243fn upload_deadline_error() -> LinkError {
244 LinkError::Transport {
245 hub: "the object store".to_string(),
246 message: "network error (upload deadline exceeded)".to_string(),
247 }
248}
249
250fn upload_attempt_timeout(deadline: std::time::Instant) -> LinkResult<std::time::Duration> {
251 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
252 if remaining.is_zero() {
253 return Err(upload_deadline_error());
254 }
255 Ok(remaining.min(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS)))
256}
257
258fn wait_for_upload_retry(deadline: std::time::Instant, attempt: usize) -> bool {
259 if attempt + 1 >= UPLOAD_ATTEMPTS {
260 return false;
261 }
262 let pause = std::time::Duration::from_millis(upload_retry_backoff_ms(attempt));
263 if deadline.saturating_duration_since(std::time::Instant::now()) <= pause {
264 return false;
265 }
266 std::thread::sleep(pause);
267 true
268}
269
270const RESERVATION_ATTEMPTS: usize = 7;
275const RESERVATION_BACKOFF_MS: [u64; RESERVATION_ATTEMPTS - 1] =
276 [500, 2_000, 5_000, 15_000, 30_000, 60_000];
277
278fn is_retryable_hub_status(status: u16) -> bool {
282 matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
283}
284
285fn is_retryable_upload_status(status: u16) -> bool {
289 matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
290}
291const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
295#[cfg(unix)]
299const V2_PULL_INSTALL_WORKERS: usize = 16;
300const V2_BULK_STREAM_FILES: usize = 256;
304const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
305const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
306const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
307
308#[derive(Debug, thiserror::Error)]
312pub enum LinkError {
313 #[error(
315 "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
316 )]
317 NoHub,
318
319 #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
321 NoCredential,
322
323 #[error(
326 "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
327 )]
328 BadKey,
329
330 #[error(
336 "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}"
337 )]
338 UnboundCredential,
339
340 #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
344 BadAgentKey {
345 message: String,
347 },
348
349 #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
351 UnsafeHub {
352 hub: String,
354 },
355
356 #[error("hub unreachable at {hub}: {message}")]
358 Transport {
359 hub: String,
361 message: String,
363 },
364
365 #[error("{what} failed (HTTP {status}): {message}")]
367 Http {
368 what: &'static str,
370 status: u16,
372 message: String,
374 code: Option<String>,
376 details: Option<Value>,
378 },
379
380 #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
383 NotJson {
384 what: &'static str,
386 status: u16,
388 },
389
390 #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
392 ResponseTooLarge {
393 limit_bytes: u64,
395 },
396
397 #[error("invalid address `{given}`: {reason}")]
399 BadAddress {
400 given: String,
402 reason: String,
404 },
405
406 #[error(
408 "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
409 )]
410 BadGrantId {
411 given: String,
413 },
414
415 #[error("refusing unsafe path from the hub: `{path}`")]
419 UnsafePath {
420 path: String,
422 },
423
424 #[error(
426 "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
427 MAX_STORE_BYTES / (1024 * 1024),
428 MAX_PACK_BYTES / (1024 * 1024)
429 )]
430 PushTooLarge {
431 detail: String,
433 },
434
435 #[error(
437 "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
438 MAX_PROPOSE_BYTES / 1024
439 )]
440 ProposeTooLarge {
441 bytes: u64,
443 },
444
445 #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
447 NotUtf8 {
448 path: String,
450 },
451
452 #[error("invalid store pack: {message}")]
454 InvalidPack {
455 message: String,
457 },
458
459 #[error("invalid signed feed: {message}")]
461 InvalidFeed {
462 message: String,
464 },
465
466 #[error(
470 "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}`"
471 )]
472 AliasRebindRequired {
473 alias: String,
474 from: String,
475 to: String,
476 },
477
478 #[error("sync conflict on {paths:?} — resolve the named files and retry")]
481 Conflict {
482 paths: Vec<String>,
484 },
485
486 #[error(
490 "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
491 )]
492 ConflictBundle {
493 bundle: String,
495 paths: Vec<String>,
497 },
498
499 #[error(
503 "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
504 )]
505 LocalPolicyTransition {
506 paths: Vec<String>,
508 },
509
510 #[error(
515 "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
516 )]
517 BulkPreviewRequired {
518 preview: Value,
520 },
521
522 #[error(
525 "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
526 )]
527 ScopedProjectionModified,
528
529 #[error(
533 "the checkout's permission scope changed — clone into a new directory to accept the new view"
534 )]
535 ScopedViewChanged,
536
537 #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
540 BrainUnavailable,
541
542 #[error(
545 "the remote brain advanced during sync — retry to converge from the new verified head"
546 )]
547 RemoteAdvancedDuringSync,
548
549 #[error(
552 "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
553 )]
554 UnsupportedPlatform {
555 operation: &'static str,
557 },
558
559 #[error(transparent)]
561 Io(#[from] std::io::Error),
562
563 #[error(transparent)]
565 Store(#[from] crate::StoreError),
566}
567
568pub type LinkResult<T> = std::result::Result<T, LinkError>;
570
571#[derive(Debug, Clone, PartialEq, Eq)]
573pub struct V2BulkConfirmation {
574 pub id: String,
576 pub digest: String,
579}
580
581impl V2BulkConfirmation {
582 pub fn parse(value: &str) -> LinkResult<Self> {
585 let (id, digest) = value
586 .split_once(':')
587 .ok_or_else(|| LinkError::InvalidPack {
588 message: "bulk confirmation must be <id>:<digest>".to_string(),
589 })?;
590 if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
591 return Err(LinkError::InvalidPack {
592 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
593 .to_string(),
594 });
595 }
596 Ok(Self {
597 id: id.to_string(),
598 digest: digest.to_string(),
599 })
600 }
601}
602
603fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
608 #[cfg(any(target_os = "linux", target_os = "macos", windows))]
609 {
610 let _ = operation;
611 Ok(())
612 }
613 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
614 {
615 Err(LinkError::UnsupportedPlatform { operation })
616 }
617}
618
619#[derive(Debug, Clone, PartialEq, Eq)]
625pub enum AddressTarget {
626 Id(String),
628 Path(String),
632}
633
634const BAD_BRAIN_REASON: &str =
637 "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
638
639const BAD_TARGET_REASON: &str =
642 "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
643
644#[derive(Debug, Clone, PartialEq, Eq)]
649pub struct Address {
650 pub brain: String,
652 pub target: Option<AddressTarget>,
654}
655
656impl Address {
657 pub fn parse(raw: &str) -> LinkResult<Address> {
661 let bad = |reason: &str| LinkError::BadAddress {
662 given: raw.to_string(),
663 reason: reason.to_string(),
664 };
665
666 let trimmed = raw.trim();
667 let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
668 if body.is_empty() {
669 return Err(bad("empty address"));
670 }
671
672 let (brain, rest) = match body.split_once('/') {
673 Some((b, r)) => (b, Some(r)),
674 None => (body, None),
675 };
676
677 if brain.is_empty() {
678 return Err(bad("missing brain reference before `/`"));
679 }
680 if !is_safe_ref(brain) {
681 return Err(bad(BAD_BRAIN_REASON));
682 }
683
684 let target = match rest {
685 None => None,
686 Some("") => return Err(bad("trailing `/` with no record id or path")),
687 Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
688 Some(r) => {
689 if !safe_store_rel_path(r) || !r.ends_with(".md") {
690 return Err(bad(BAD_TARGET_REASON));
691 }
692 Some(AddressTarget::Path(r.to_string()))
693 }
694 };
695
696 Ok(Address {
697 brain: brain.to_string(),
698 target,
699 })
700 }
701}
702
703fn is_safe_ref(s: &str) -> bool {
706 !s.is_empty()
707 && s.len() <= 64
708 && s.bytes()
709 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
710}
711
712pub fn is_valid_handle(s: &str) -> bool {
715 is_safe_ref(s)
716}
717
718pub fn safe_store_rel_path(p: &str) -> bool {
724 if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
725 return false;
726 }
727 if !p
728 .bytes()
729 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
730 {
731 return false;
732 }
733 p.split('/')
734 .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
735}
736
737fn require_safe_ref(brain: &str) -> LinkResult<()> {
745 if is_safe_ref(brain) {
746 Ok(())
747 } else {
748 Err(LinkError::BadAddress {
749 given: brain.to_string(),
750 reason: BAD_BRAIN_REASON.to_string(),
751 })
752 }
753}
754
755fn require_valid_handle(handle: &str) -> LinkResult<()> {
757 if is_valid_handle(handle) {
758 Ok(())
759 } else {
760 Err(LinkError::BadAddress {
761 given: handle.to_string(),
762 reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
763 })
764 }
765}
766
767fn require_safe_grant_id(id: &str) -> LinkResult<()> {
771 if is_safe_ref(id) {
772 Ok(())
773 } else {
774 Err(LinkError::BadGrantId {
775 given: id.to_string(),
776 })
777 }
778}
779
780#[derive(Debug, Clone)]
786pub struct HubConfig {
787 pub hub: String,
789 pub key: Option<String>,
791 pub agent_key: Option<AgentSigningKey>,
794 pub brain_key: Option<AgentSigningKey>,
797 pub state_dir: PathBuf,
800 store_selected: bool,
803}
804
805#[derive(Clone)]
808pub struct AgentSigningKey {
809 pkcs8: Vec<u8>,
810 pub multikey: String,
812 pub public_key_spki: String,
814}
815
816impl std::fmt::Debug for AgentSigningKey {
817 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
818 f.debug_struct("AgentSigningKey")
819 .field("multikey", &self.multikey)
820 .field("pkcs8", &"<redacted>")
821 .finish()
822 }
823}
824
825impl HubConfig {
826 pub fn require_key(&self) -> LinkResult<&str> {
829 self.key.as_deref().ok_or(LinkError::NoCredential)
830 }
831}
832
833pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
838 let explicit_hub = flag_hub
839 .map(str::to_string)
840 .or_else(|| env_nonempty(HUB_URL_ENV));
841 let selected_by_store = explicit_hub.is_none();
842 let hub = explicit_hub
843 .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
844 .ok_or(LinkError::NoHub)?;
845 let hub = hub.trim().trim_end_matches('/').to_string();
846 assert_safe_hub(&hub)?;
847 if selected_by_store {
848 let parsed =
849 url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
850 if !parsed.scheme().eq_ignore_ascii_case("https")
854 || (parsed.path() != "/" && !parsed.path().is_empty())
855 {
856 return Err(LinkError::UnsafeHub { hub });
857 }
858 }
859
860 let key = match env_nonempty(HUB_KEY_ENV) {
861 Some(raw) => Some(clean_key(&raw)?),
862 None => None,
863 };
864
865 let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
866 Some(path) => Some(load_agent_key(Path::new(&path))?),
867 None => None,
868 };
869
870 let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
871 Some(path) => Some(load_agent_key(Path::new(&path))?),
872 None => None,
873 };
874
875 if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
882 let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
883 .and_then(|value| normalized_origin(&value).ok());
884 let selected_origin = normalized_origin(&hub)?;
885 if bound.as_deref() != Some(selected_origin.as_str()) {
886 return Err(LinkError::UnboundCredential);
887 }
888 }
889
890 Ok(HubConfig {
891 hub,
892 key,
893 agent_key,
894 brain_key,
895 state_dir: toolkit_state_dir()?,
896 store_selected: selected_by_store,
897 })
898}
899
900fn toolkit_state_dir() -> LinkResult<PathBuf> {
901 if let Some(path) = env_nonempty(STATE_DIR_ENV) {
902 let path = PathBuf::from(path);
903 if !path.is_absolute() {
904 return Err(LinkError::UnsafePath {
905 path: path.display().to_string(),
906 });
907 }
908 return Ok(path);
909 }
910 #[cfg(windows)]
911 if let Some(base) = env_nonempty("LOCALAPPDATA") {
912 let base = PathBuf::from(base);
913 if base.is_absolute() {
914 return Ok(base.join("dbmd").join("state"));
915 }
916 }
917 #[cfg(windows)]
918 {
919 Err(LinkError::Io(std::io::Error::new(
920 std::io::ErrorKind::NotFound,
921 format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
922 )))
923 }
924 #[cfg(not(windows))]
925 if let Some(base) = env_nonempty("XDG_STATE_HOME") {
926 let base = PathBuf::from(base);
927 if base.is_absolute() {
928 return Ok(base.join("dbmd"));
929 }
930 }
931 #[cfg(not(windows))]
932 let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
933 LinkError::Io(std::io::Error::new(
934 std::io::ErrorKind::NotFound,
935 format!("cannot locate user state; set {STATE_DIR_ENV}"),
936 ))
937 })?);
938 #[cfg(not(windows))]
939 if !home.is_absolute() {
940 return Err(LinkError::UnsafePath {
941 path: home.display().to_string(),
942 });
943 }
944 #[cfg(target_os = "macos")]
945 {
946 Ok(home
947 .join("Library")
948 .join("Application Support")
949 .join("dbmd")
950 .join("state"))
951 }
952 #[cfg(all(not(target_os = "macos"), not(windows)))]
953 {
954 Ok(home.join(".local").join("state").join("dbmd"))
955 }
956}
957
958fn normalized_origin(value: &str) -> LinkResult<String> {
959 let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
960 hub: value.to_string(),
961 })?;
962 if !(parsed.scheme().eq_ignore_ascii_case("https")
963 || parsed.scheme().eq_ignore_ascii_case("http"))
964 || !parsed.username().is_empty()
965 || parsed.password().is_some()
966 || (parsed.path() != "/" && !parsed.path().is_empty())
967 || parsed.query().is_some()
968 || parsed.fragment().is_some()
969 {
970 return Err(LinkError::UnsafeHub {
971 hub: value.to_string(),
972 });
973 }
974 let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
975 hub: value.to_string(),
976 })?;
977 let host = if host.contains(':') {
978 format!("[{host}]")
979 } else {
980 host.to_ascii_lowercase()
981 };
982 let port = parsed
983 .port_or_known_default()
984 .ok_or_else(|| LinkError::UnsafeHub {
985 hub: value.to_string(),
986 })?;
987 let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
988 || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
989 Ok(format!(
990 "{}://{}{}",
991 parsed.scheme().to_ascii_lowercase(),
992 host,
993 if default {
994 String::new()
995 } else {
996 format!(":{port}")
997 }
998 ))
999}
1000
1001const ED25519_SPKI_PREFIX: [u8; 12] = [
1008 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1009];
1010
1011fn bad_agent_key(message: &str) -> LinkError {
1012 LinkError::BadAgentKey {
1013 message: message.to_string(),
1014 }
1015}
1016
1017fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
1018 ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
1022 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
1023 .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
1024}
1025
1026fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
1028 use ring::signature::KeyPair as _;
1029 let mut spki = Vec::with_capacity(44);
1030 spki.extend_from_slice(&ED25519_SPKI_PREFIX);
1031 spki.extend_from_slice(pair.public_key().as_ref());
1032 (
1033 URL_SAFE_NO_PAD.encode(&spki),
1034 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
1035 )
1036}
1037
1038pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1042 load_agent_key(path)
1043}
1044
1045fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1047 #[cfg(unix)]
1048 let file = {
1049 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1050 use std::os::unix::ffi::OsStrExt as _;
1051 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1052 .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1053 let leaf = path
1054 .file_name()
1055 .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1056 let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1057 let fd = unsafe {
1058 libc::openat(
1059 parent.as_raw_fd(),
1060 leaf.as_ptr(),
1061 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1062 )
1063 };
1064 if fd < 0 {
1065 return Err(bad_agent_key(
1066 "the key path must be an existing regular file without symlink ancestors",
1067 ));
1068 }
1069 unsafe { std::fs::File::from_raw_fd(fd) }
1070 };
1071 #[cfg(not(unix))]
1072 let file = std::fs::File::open(path)
1073 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1074 let metadata = file
1075 .metadata()
1076 .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1077 if !metadata.is_file() {
1078 return Err(bad_agent_key("the key path must be a regular file"));
1079 }
1080 #[cfg(unix)]
1081 {
1082 use std::os::unix::fs::PermissionsExt as _;
1083 if metadata.permissions().mode() & 0o077 != 0 {
1084 return Err(bad_agent_key(
1085 "the key file is accessible to group/other; set mode 0600",
1086 ));
1087 }
1088 }
1089 let mut text = String::new();
1090 file.take(1024 * 1024 + 1)
1091 .read_to_string(&mut text)
1092 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1093 if text.len() > 1024 * 1024 {
1094 return Err(bad_agent_key("the key file exceeds the size limit"));
1095 }
1096 let pkcs8 = URL_SAFE_NO_PAD
1097 .decode(text.trim())
1098 .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1099 let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1100 Ok(AgentSigningKey {
1101 pkcs8,
1102 multikey,
1103 public_key_spki,
1104 })
1105}
1106
1107fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1113 #[cfg(unix)]
1114 let (mut file, parent, leaf) = {
1115 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1116 use std::os::unix::ffi::OsStrExt as _;
1117 let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1118 let leaf_name = path
1119 .file_name()
1120 .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1121 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1122 let fd = unsafe {
1123 libc::openat(
1124 parent.as_raw_fd(),
1125 leaf.as_ptr(),
1126 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1127 0o600,
1128 )
1129 };
1130 if fd < 0 {
1131 let error = std::io::Error::last_os_error();
1132 if error.kind() == std::io::ErrorKind::AlreadyExists {
1133 return Err(bad_agent_key(
1134 "the output file already exists — refusing to overwrite a key",
1135 ));
1136 }
1137 return Err(error.into());
1138 }
1139 (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1140 };
1141 #[cfg(not(unix))]
1142 let mut file = std::fs::OpenOptions::new()
1143 .write(true)
1144 .create_new(true)
1145 .open(path)
1146 .map_err(|error| {
1147 if error.kind() == std::io::ErrorKind::AlreadyExists {
1148 bad_agent_key("the output file already exists — refusing to overwrite a key")
1149 } else {
1150 LinkError::Io(error)
1151 }
1152 })?;
1153 if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1154 drop(file);
1155 #[cfg(unix)]
1156 let _ =
1157 unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1158 #[cfg(not(unix))]
1159 let _ = std::fs::remove_file(path);
1160 return Err(LinkError::Io(error));
1161 }
1162 drop(file);
1163 #[cfg(unix)]
1164 parent.sync_all()?;
1165 Ok(())
1166}
1167
1168#[derive(Debug, Serialize)]
1171pub struct GeneratedAgentKey {
1172 pub multikey: String,
1174 #[serde(rename = "publicKeySpki")]
1176 pub public_key_spki: String,
1177 #[serde(rename = "keyFile")]
1179 pub key_file: String,
1180}
1181
1182pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1187 require_hardened_filesystem("key generation")?;
1188 let rng = ring::rand::SystemRandom::new();
1189 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1190 .map_err(|_| bad_agent_key("key generation failed"))?;
1191 let pair = agent_keypair(pkcs8.as_ref())?;
1192 let (spki_b64u, multikey) = public_identity_for(&pair);
1193
1194 write_secret_new(
1195 out,
1196 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1197 )?;
1198
1199 Ok(GeneratedAgentKey {
1200 multikey,
1201 public_key_spki: spki_b64u,
1202 key_file: out.display().to_string(),
1203 })
1204}
1205
1206fn linkmd_sig_header(
1215 key: &AgentSigningKey,
1216 origin: &str,
1217 method: &str,
1218 path: &str,
1219 body: Option<&str>,
1220) -> LinkResult<String> {
1221 let ts = std::time::SystemTime::now()
1222 .duration_since(std::time::UNIX_EPOCH)
1223 .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1224 .as_secs();
1225 let body_hash = match body {
1226 Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1227 None => "-".to_string(),
1228 };
1229 let canonical = format!(
1230 "v2\n{}\n{}\n{}\n{}\n{}",
1231 origin,
1232 method.to_uppercase(),
1233 path,
1234 ts,
1235 body_hash
1236 );
1237 let pair = agent_keypair(&key.pkcs8)?;
1238 let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1239 let fingerprint = key.multikey.trim_start_matches("ed25519:");
1240 Ok(format!(
1241 "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1242 ))
1243}
1244
1245#[derive(Serialize)]
1252struct WireFeedFile {
1253 path: String,
1254 sha256: String,
1255 bytes: u64,
1256}
1257
1258#[derive(Serialize)]
1261struct UnsignedWireEntry<'a> {
1262 v: u8,
1263 seq: u64,
1264 ts: String,
1265 brain: &'a str,
1266 public_key: &'a str,
1267 kind: &'a str,
1268 op: &'a str,
1269 pack_sha256: &'a str,
1270 files: &'a [WireFeedFile],
1271 removed: &'a [String],
1272 prev_entry_hash: Option<&'a str>,
1273}
1274
1275fn self_custody_entry(
1281 key: &AgentSigningKey,
1282 seq: u64,
1283 ts: String,
1284 pack_sha256: &str,
1285 files: &[WireFeedFile],
1286 prev_entry_hash: Option<&str>,
1287) -> LinkResult<String> {
1288 let removed: [String; 0] = [];
1289 let unsigned = serde_json::to_string(&UnsignedWireEntry {
1290 v: 1,
1291 seq,
1292 ts,
1293 brain: &key.multikey,
1294 public_key: &key.public_key_spki,
1295 kind: "push",
1296 op: "snapshot",
1297 pack_sha256,
1298 files,
1299 removed: &removed,
1300 prev_entry_hash,
1301 })
1302 .expect("serialize feed entry");
1303 let pair = agent_keypair(&key.pkcs8)?;
1304 let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1305 Ok(format!(
1306 "{},\"sig\":\"{}\"}}",
1307 &unsigned[..unsigned.len() - 1],
1308 sig
1309 ))
1310}
1311
1312fn env_nonempty(name: &str) -> Option<String> {
1315 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1316}
1317
1318fn config_file_hub(path: &Path) -> Option<String> {
1323 const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1324 #[cfg(unix)]
1325 let file = {
1326 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1327 use std::os::unix::ffi::OsStrExt as _;
1328 let parent =
1329 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1330 let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1331 let fd = unsafe {
1332 libc::openat(
1333 parent.as_raw_fd(),
1334 leaf.as_ptr(),
1335 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1336 )
1337 };
1338 if fd < 0 {
1339 return None;
1340 }
1341 unsafe { std::fs::File::from_raw_fd(fd) }
1342 };
1343 #[cfg(not(unix))]
1344 let file = std::fs::File::open(path).ok()?;
1345 let metadata = file.metadata().ok()?;
1346 if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1347 return None;
1348 }
1349 let mut bytes = Vec::with_capacity(metadata.len() as usize);
1350 file.take(MAX_CONFIG_BYTES + 1)
1351 .read_to_end(&mut bytes)
1352 .ok()?;
1353 if bytes.len() as u64 > MAX_CONFIG_BYTES {
1354 return None;
1355 }
1356 let text = String::from_utf8(bytes).ok()?;
1357 for line in text.lines() {
1358 let line = line.trim();
1359 if line.is_empty() || line.starts_with('#') {
1360 continue;
1361 }
1362 if let Some((k, v)) = line.split_once('=') {
1363 if k.trim() == "hub" {
1364 let v = v.trim();
1365 if !v.is_empty() {
1366 return Some(v.to_string());
1367 }
1368 }
1369 }
1370 }
1371 None
1372}
1373
1374fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1377 let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1378 hub: hub.to_string(),
1379 })?;
1380 if !(parsed.scheme().eq_ignore_ascii_case("https")
1381 || parsed.scheme().eq_ignore_ascii_case("http"))
1382 || !parsed.username().is_empty()
1383 || parsed.password().is_some()
1384 || (parsed.path() != "/" && !parsed.path().is_empty())
1385 || parsed.query().is_some()
1386 || parsed.fragment().is_some()
1387 {
1388 return Err(LinkError::UnsafeHub {
1389 hub: hub.to_string(),
1390 });
1391 }
1392 let loopback = match parsed.host() {
1393 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1394 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1395 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1396 None => false,
1397 };
1398 if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1399 Ok(())
1400 } else {
1401 Err(LinkError::UnsafeHub {
1402 hub: hub.to_string(),
1403 })
1404 }
1405}
1406
1407fn clean_key(raw: &str) -> LinkResult<String> {
1412 let k = raw.trim();
1413 if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1414 return Err(LinkError::BadKey);
1415 }
1416 Ok(k.to_string())
1417}
1418
1419#[derive(Debug)]
1425pub struct HubResponse {
1426 pub status: u16,
1428 pub body: Option<Value>,
1430}
1431
1432struct RawHubResponse {
1433 status: u16,
1434 body: Vec<u8>,
1435}
1436
1437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1439enum Auth {
1440 Required,
1442 None,
1444 Optional,
1448}
1449
1450fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1451 ureq::AgentBuilder::new()
1452 .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1453 .redirects(0)
1457 .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1458 .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1459 .timeout_write(overall)
1460 .timeout(overall)
1461}
1462
1463fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1464 hub_agent_with_timeout(
1465 cfg,
1466 std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1467 )
1468}
1469
1470fn hub_agent_with_timeout(
1471 cfg: &HubConfig,
1472 overall: std::time::Duration,
1473) -> LinkResult<ureq::Agent> {
1474 if !cfg.store_selected {
1475 return Ok(agent_builder_with_timeout(overall).build());
1476 }
1477 let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1478 hub: cfg.hub.clone(),
1479 })?;
1480 pinned_public_agent_pooled(
1481 &parsed,
1482 false,
1483 "store-selected hub",
1484 AgentShape {
1485 overall,
1486 ..AgentShape::default()
1487 },
1488 )
1489}
1490
1491fn request_raw(
1496 cfg: &HubConfig,
1497 method: &str,
1498 path: &str,
1499 body: Option<&Value>,
1500 auth: Auth,
1501 max_response_bytes: u64,
1502) -> LinkResult<RawHubResponse> {
1503 let http = hub_agent(cfg)?;
1504 request_raw_with_agent(
1505 cfg,
1506 &http,
1507 method,
1508 path,
1509 body,
1510 RawRequestOptions {
1511 auth,
1512 max_response_bytes,
1513 request_id: None,
1514 },
1515 )
1516}
1517
1518struct RawRequestOptions<'a> {
1519 auth: Auth,
1520 max_response_bytes: u64,
1521 request_id: Option<&'a str>,
1522}
1523
1524fn request_raw_with_agent(
1525 cfg: &HubConfig,
1526 http: &ureq::Agent,
1527 method: &str,
1528 path: &str,
1529 body: Option<&Value>,
1530 options: RawRequestOptions<'_>,
1531) -> LinkResult<RawHubResponse> {
1532 let url = format!("{}{}", cfg.hub, path);
1533 let encoded_body = body.map(Value::to_string);
1534 let origin = normalized_origin(&cfg.hub)?;
1535 let credential = match options.auth {
1538 Auth::Required => Some(match &cfg.agent_key {
1539 Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1540 None => format!("Bearer {}", cfg.require_key()?),
1541 }),
1542 Auth::Optional => match &cfg.agent_key {
1543 Some(key) => Some(linkmd_sig_header(
1544 key,
1545 &origin,
1546 method,
1547 path,
1548 encoded_body.as_deref(),
1549 )?),
1550 None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1551 },
1552 Auth::None => None,
1553 };
1554 let result = with_connect_retries(|| {
1555 let mut req = http.request(method, &url);
1556 if let Some(value) = &credential {
1557 req = req.set("authorization", value);
1558 }
1559 if let Some(value) = options.request_id {
1560 req = req.set("x-request-id", value);
1561 }
1562 match &encoded_body {
1563 Some(value) => req
1564 .set("content-type", "application/json")
1565 .send_string(value)
1566 .map_err(Box::new),
1567 None => req.call().map_err(Box::new),
1568 }
1569 });
1570 let resp = match result {
1571 Ok(resp) => resp,
1572 Err(error) => match *error {
1573 ureq::Error::Status(_, resp) => resp,
1574 ureq::Error::Transport(error) => {
1575 return Err(LinkError::Transport {
1576 hub: cfg.hub.clone(),
1577 message: error.to_string(),
1578 });
1579 }
1580 },
1581 };
1582
1583 let status = resp.status();
1584 let buf = read_response_body(resp, options.max_response_bytes + 1, &cfg.hub)?;
1585 if buf.len() as u64 > options.max_response_bytes {
1586 return Err(LinkError::ResponseTooLarge {
1587 limit_bytes: options.max_response_bytes,
1588 });
1589 }
1590 Ok(RawHubResponse { status, body: buf })
1591}
1592
1593fn request_capped(
1594 cfg: &HubConfig,
1595 method: &str,
1596 path: &str,
1597 body: Option<&Value>,
1598 auth: Auth,
1599 max_response_bytes: u64,
1600) -> LinkResult<HubResponse> {
1601 let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1602 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1603 Ok(HubResponse {
1604 status: raw.status,
1605 body: parsed,
1606 })
1607}
1608
1609fn request_patient(
1621 cfg: &HubConfig,
1622 method: &str,
1623 path: &str,
1624 body: Option<&Value>,
1625 auth: Auth,
1626) -> LinkResult<HubResponse> {
1627 let http = hub_agent_with_timeout(
1628 cfg,
1629 std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1630 )?;
1631 let mut attempt = 0;
1632 loop {
1633 let sent = request_raw_with_agent(
1634 cfg,
1635 &http,
1636 method,
1637 path,
1638 body,
1639 RawRequestOptions {
1640 auth,
1641 max_response_bytes: MAX_RESPONSE_BYTES,
1642 request_id: None,
1643 },
1644 );
1645 match sent {
1646 Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1647 std::thread::sleep(std::time::Duration::from_millis(
1648 COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1649 ));
1650 attempt += 1;
1651 }
1652 Err(error) => return Err(error),
1653 Ok(raw) => {
1654 return Ok(HubResponse {
1655 status: raw.status,
1656 body: serde_json::from_slice(&raw.body).ok(),
1657 })
1658 }
1659 }
1660 }
1661}
1662
1663fn request(
1664 cfg: &HubConfig,
1665 method: &str,
1666 path: &str,
1667 body: Option<&Value>,
1668 auth: Auth,
1669) -> LinkResult<HubResponse> {
1670 request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1671}
1672
1673fn request_with_request_id(
1678 cfg: &HubConfig,
1679 method: &str,
1680 path: &str,
1681 body: Option<&Value>,
1682 auth: Auth,
1683 request_id: &str,
1684) -> LinkResult<HubResponse> {
1685 if request_id.is_empty()
1686 || request_id.len() > 128
1687 || !request_id
1688 .bytes()
1689 .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1690 {
1691 return Err(invalid_feed("hub returned an unsafe request id"));
1692 }
1693 let http = hub_agent_with_timeout(
1696 cfg,
1697 std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1698 )?;
1699 let raw = request_raw_with_agent(
1700 cfg,
1701 &http,
1702 method,
1703 path,
1704 body,
1705 RawRequestOptions {
1706 auth,
1707 max_response_bytes: MAX_RESPONSE_BYTES,
1708 request_id: Some(request_id),
1709 },
1710 )?;
1711 Ok(HubResponse {
1712 status: raw.status,
1713 body: serde_json::from_slice(&raw.body).ok(),
1714 })
1715}
1716
1717fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1718 if (200..300).contains(&r.status) {
1719 return Ok(r.body);
1720 }
1721 ensure_ok(
1722 HubResponse {
1723 status: r.status,
1724 body: serde_json::from_slice(&r.body).ok(),
1725 },
1726 what,
1727 )
1728 .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1729}
1730
1731fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1736 matches!(
1737 kind,
1738 ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1739 )
1740}
1741
1742fn with_connect_retries(
1743 mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1744) -> Result<ureq::Response, Box<ureq::Error>> {
1745 let mut attempt = 0;
1746 loop {
1747 match send() {
1748 Err(error)
1749 if matches!(
1750 error.as_ref(),
1751 ureq::Error::Transport(transport)
1752 if is_pre_request_transport(transport.kind())
1753 ) && attempt + 1 < CONNECT_ATTEMPTS =>
1754 {
1755 std::thread::sleep(std::time::Duration::from_millis(
1756 CONNECT_RETRY_BACKOFF_MS[attempt],
1757 ));
1758 attempt += 1;
1759 }
1760 result => return result,
1761 }
1762 }
1763}
1764
1765fn hub_is_loopback(hub: &str) -> bool {
1766 url::Url::parse(hub).ok().is_some_and(|parsed| {
1767 parsed.host().is_some_and(|host| match host {
1768 url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1769 url::Host::Ipv4(ip) => ip.is_loopback(),
1770 url::Host::Ipv6(ip) => ip.is_loopback(),
1771 })
1772 })
1773}
1774
1775fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1779 let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1780 message: "the hub returned an invalid object-store URL".to_string(),
1781 })?;
1782 let allow_private = hub_is_loopback(&cfg.hub)
1783 || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1784 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1785 || !parsed.username().is_empty()
1786 || parsed.password().is_some()
1787 || parsed.fragment().is_some()
1788 {
1789 return Err(LinkError::InvalidPack {
1790 message: "the hub returned an unsafe object-store URL".to_string(),
1791 });
1792 }
1793 Ok((parsed, allow_private))
1794}
1795
1796fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1797 let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1798 pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1799 LinkError::InvalidPack {
1800 message: "the hub returned an object-store URL with an unsafe network target"
1801 .to_string(),
1802 }
1803 })
1804}
1805
1806fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1815 let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1816 let authority = (
1817 first.host_str()?.to_string(),
1818 first.port_or_known_default()?,
1819 );
1820 for raw in &urls[1..] {
1821 let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1822 if (parsed.host_str()?, parsed.port_or_known_default()?)
1823 != (authority.0.as_str(), authority.1)
1824 {
1825 return None;
1826 }
1827 }
1828 pinned_public_agent_pooled(
1829 &first,
1830 allow_private,
1831 "object-store URL",
1832 AgentShape {
1833 idle_per_host: V2_UPLOAD_CONCURRENCY,
1834 ..AgentShape::default()
1835 },
1836 )
1837 .ok()
1838}
1839
1840fn object_store_transport_error(error: ureq::Transport) -> LinkError {
1846 LinkError::Transport {
1847 hub: "the object store".to_string(),
1848 message: format!("network error ({:?})", error.kind()),
1849 }
1850}
1851
1852fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1853 let http = presigned_agent(cfg, raw)?;
1854 let deadline = std::time::Instant::now()
1855 .checked_add(std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS))
1856 .ok_or_else(upload_deadline_error)?;
1857 let mut attempt = 0;
1858 let result = loop {
1859 let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
1863 if let Some(map) = headers.as_object() {
1864 for (name, value) in map {
1865 if let Some(value) = value.as_str() {
1866 req = req.set(name, value);
1867 }
1868 }
1869 }
1870 match req.send_bytes(bytes) {
1871 Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
1877 attempt += 1;
1878 }
1879 Err(ureq::Error::Status(status, _))
1880 if status != 412
1881 && is_retryable_upload_status(status)
1882 && wait_for_upload_retry(deadline, attempt) =>
1883 {
1884 attempt += 1;
1885 }
1886 result => break result,
1887 }
1888 };
1889 match result {
1890 Ok(resp) if (200..300).contains(&resp.status()) => {
1891 drain_presigned_response(resp);
1892 Ok(())
1893 }
1894 Ok(resp) => Err(presigned_upload_refusal(resp)),
1895 Err(error) => match error {
1896 ureq::Error::Status(412, _) => Ok(()),
1901 ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
1902 ureq::Error::Transport(err) => Err(object_store_transport_error(err)),
1903 },
1904 }
1905}
1906
1907fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
1916 let mut buf = Vec::new();
1917 response
1918 .into_reader()
1919 .take(limit)
1920 .read_to_end(&mut buf)
1921 .map_err(|error| LinkError::Transport {
1922 hub: peer.to_string(),
1923 message: error.to_string(),
1924 })?;
1925 Ok(buf)
1926}
1927
1928fn drain_presigned_response(response: ureq::Response) {
1933 let mut reader = response.into_reader().take(64 * 1024);
1934 let _ = std::io::copy(&mut reader, &mut std::io::sink());
1935}
1936
1937fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
1940 let status = response.status();
1941 let detail = response
1942 .into_string()
1943 .ok()
1944 .map(|body| body.chars().take(400).collect::<String>())
1945 .filter(|body| !body.trim().is_empty());
1946 LinkError::Http {
1947 what: "pack upload",
1948 status,
1949 message: match detail {
1950 Some(body) => format!(
1951 "object store rejected the upload: {}",
1952 body.replace('\n', " ")
1953 ),
1954 None => "object store rejected the upload".to_string(),
1955 },
1956 code: None,
1957 details: None,
1958 }
1959}
1960
1961fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1962 max_bytes.checked_add(1)
1963}
1964
1965fn presigned_download_read_limit() -> u64 {
1966 one_past_bounded_limit(MAX_PACK_BYTES)
1967 .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1968}
1969
1970fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1971 let http = presigned_agent(cfg, raw)?;
1972 let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1973 Ok(resp) => resp,
1974 Err(error) => match *error {
1975 ureq::Error::Status(_, resp) => {
1976 return Err(LinkError::Http {
1977 what: "pack download",
1978 status: resp.status(),
1979 message: "object store rejected the download".to_string(),
1980 code: None,
1981 details: None,
1982 });
1983 }
1984 ureq::Error::Transport(err) => {
1985 return Err(LinkError::Transport {
1986 hub: "the object store".to_string(),
1987 message: err.to_string(),
1988 });
1989 }
1990 },
1991 };
1992 if !(200..300).contains(&resp.status()) {
1993 return Err(LinkError::Http {
1994 what: "pack download",
1995 status: resp.status(),
1996 message: "object store rejected the download".to_string(),
1997 code: None,
1998 details: None,
1999 });
2000 }
2001 let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
2002 if bytes.len() as u64 > MAX_PACK_BYTES {
2003 return Err(LinkError::InvalidPack {
2004 message: "download exceeds the compressed-size limit".to_string(),
2005 });
2006 }
2007 Ok(bytes)
2008}
2009
2010fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
2014 if !(200..300).contains(&r.status) {
2015 let message = r
2016 .body
2017 .as_ref()
2018 .and_then(|b| b.get("error"))
2019 .and_then(Value::as_str)
2020 .unwrap_or("unknown error")
2021 .to_string();
2022 let code = r
2023 .body
2024 .as_ref()
2025 .and_then(|b| b.get("code"))
2026 .and_then(Value::as_str)
2027 .map(str::to_string);
2028 let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
2029 return Err(LinkError::Http {
2030 what,
2031 status: r.status,
2032 message,
2033 code,
2034 details,
2035 });
2036 }
2037 r.body.ok_or(LinkError::NotJson {
2038 what,
2039 status: r.status,
2040 })
2041}
2042
2043fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2052 match ip {
2053 std::net::IpAddr::V4(ip) => {
2054 let [a, b, c, _] = ip.octets();
2055 !(a == 0
2056 || a == 10
2057 || a == 127
2058 || (a == 100 && (64..=127).contains(&b))
2059 || (a == 169 && b == 254)
2060 || (a == 172 && (16..=31).contains(&b))
2061 || (a == 192 && b == 0 && c == 0)
2062 || (a == 192 && b == 0 && c == 2)
2063 || (a == 192 && b == 88 && c == 99)
2064 || (a == 192 && b == 168)
2065 || (a == 198 && (b == 18 || b == 19))
2066 || (a == 198 && b == 51 && c == 100)
2067 || (a == 203 && b == 0 && c == 113)
2068 || a >= 224)
2069 }
2070 std::net::IpAddr::V6(ip) => {
2071 let segments = ip.segments();
2072 (segments[0] & 0xe000) == 0x2000
2077 && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2078 && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2079 && segments[0] != 0x2002
2080 && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2081 }
2082 }
2083}
2084
2085#[derive(Clone)]
2086struct PinnedRegistryResolver {
2087 netloc: String,
2088 addresses: Vec<std::net::SocketAddr>,
2089}
2090
2091impl ureq::Resolver for PinnedRegistryResolver {
2092 fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2093 if requested == self.netloc {
2094 Ok(self.addresses.clone())
2095 } else {
2096 Err(std::io::Error::new(
2097 std::io::ErrorKind::PermissionDenied,
2098 "registry request attempted to resolve an unvalidated authority",
2099 ))
2100 }
2101 }
2102}
2103
2104fn pinned_public_agent(
2105 url: &url::Url,
2106 allow_private: bool,
2107 label: &str,
2108) -> LinkResult<ureq::Agent> {
2109 pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2110}
2111
2112struct AgentShape {
2117 idle_per_host: usize,
2118 overall: std::time::Duration,
2119}
2120
2121impl Default for AgentShape {
2122 fn default() -> Self {
2123 Self {
2124 idle_per_host: 1,
2125 overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2126 }
2127 }
2128}
2129
2130fn pinned_public_agent_pooled(
2131 url: &url::Url,
2132 allow_private: bool,
2133 label: &str,
2134 shape: AgentShape,
2135) -> LinkResult<ureq::Agent> {
2136 let host = url
2137 .host_str()
2138 .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2139 let port = url
2140 .port_or_known_default()
2141 .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2142 let addresses = resolve_addresses_with_deadline(
2143 host,
2144 port,
2145 std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2146 )
2147 .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2148 if addresses.is_empty() {
2149 return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2150 }
2151 if !allow_private
2152 && addresses
2153 .iter()
2154 .any(|address| !is_public_registry_ip(address.ip()))
2155 {
2156 return Err(invalid_feed(format!(
2157 "{label} resolves to a non-public address"
2158 )));
2159 }
2160 let netloc = if host.contains(':') {
2161 format!("[{host}]:{port}")
2162 } else {
2163 format!("{host}:{port}")
2164 };
2165 Ok(agent_builder_with_timeout(shape.overall)
2166 .max_idle_connections_per_host(shape.idle_per_host.max(1))
2167 .resolver(PinnedRegistryResolver { netloc, addresses })
2168 .build())
2169}
2170
2171fn resolve_addresses_with_deadline(
2176 host: &str,
2177 port: u16,
2178 timeout: std::time::Duration,
2179) -> std::io::Result<Vec<std::net::SocketAddr>> {
2180 use std::net::ToSocketAddrs as _;
2181
2182 let host = host.to_string();
2183 let (send, receive) = std::sync::mpsc::sync_channel(1);
2184 std::thread::Builder::new()
2185 .name("dbmd-dns".to_string())
2186 .spawn(move || {
2187 let result = (host.as_str(), port)
2188 .to_socket_addrs()
2189 .map(|addresses| addresses.collect());
2190 let _ = send.send(result);
2191 })
2192 .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2193 match receive.recv_timeout(timeout) {
2194 Ok(result) => result,
2195 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2196 std::io::ErrorKind::TimedOut,
2197 "resolution exceeded its deadline",
2198 )),
2199 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2200 "resolver stopped without returning a result",
2201 )),
2202 }
2203}
2204
2205fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2206 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2207 pinned_public_agent(url, allow_private, "registry home")
2208}
2209
2210fn get_json_absolute(url: &str) -> LinkResult<Value> {
2215 let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2216 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2217 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2218 || !parsed.username().is_empty()
2219 || parsed.password().is_some()
2220 || parsed.query().is_some()
2221 || parsed.fragment().is_some()
2222 {
2223 return Err(invalid_feed("unsafe registry home URL"));
2224 }
2225 let http = registry_agent(&parsed)?;
2226 let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2227 Ok(resp) => resp,
2228 Err(error) => match *error {
2229 ureq::Error::Status(status, resp) => {
2230 let _ = resp;
2231 return Err(LinkError::Http {
2232 what: "registry home fetch",
2233 status,
2234 message: "the home node rejected the card request".to_string(),
2235 code: None,
2236 details: None,
2237 });
2238 }
2239 ureq::Error::Transport(err) => {
2240 return Err(LinkError::Transport {
2241 hub: url.to_string(),
2242 message: err.to_string(),
2243 });
2244 }
2245 },
2246 };
2247 if !(200..300).contains(&resp.status()) {
2248 return Err(LinkError::Http {
2249 what: "registry home fetch",
2250 status: resp.status(),
2251 message: "the home node returned a redirect or error".to_string(),
2252 code: None,
2253 details: None,
2254 });
2255 }
2256 let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2257 if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2258 return Err(LinkError::ResponseTooLarge {
2259 limit_bytes: MAX_REGISTRY_CARD_BYTES,
2260 });
2261 }
2262 serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2263 message: "the home node returned invalid JSON".to_string(),
2264 })
2265}
2266
2267pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2274 require_safe_ref(handle)?;
2275 let trust_directory = open_trust_dir(cfg)?;
2279 let reg = request_capped(
2280 cfg,
2281 "GET",
2282 &format!("/api/hub/registry/{handle}"),
2283 None,
2284 Auth::None,
2285 MAX_REGISTRY_CARD_BYTES,
2286 )?;
2287 if reg.status == 404 {
2288 return Ok(None);
2289 }
2290 let body = ensure_ok(reg, "registry resolve")?;
2291 let home = body
2292 .get("home")
2293 .and_then(Value::as_str)
2294 .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2295 let brain = body
2296 .get("brain")
2297 .and_then(Value::as_str)
2298 .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2299 if !crate::ulid::is_ulid(brain) {
2300 return Err(invalid_feed(
2301 "registry entry brain is not a canonical lowercase ULID",
2302 ));
2303 }
2304 let want_fp = body
2305 .get("identity")
2306 .and_then(|i| i.get("fingerprint"))
2307 .and_then(Value::as_str)
2308 .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2309
2310 let home = home.trim_end_matches('/');
2311 let origin = normalized_origin(home)?;
2312 if origin != home {
2313 return Err(invalid_feed(
2314 "registry home must be an origin without a path, query, or fragment",
2315 ));
2316 }
2317 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2318 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2319 if let Some(binding) = &alias_binding {
2320 if binding
2321 .home
2322 .as_deref()
2323 .is_some_and(|pinned_home| pinned_home != home)
2324 {
2325 return Err(invalid_feed(
2326 "registry relocated a pinned handle to a different home",
2327 ));
2328 }
2329 }
2330 let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2331 if card.get("id").and_then(Value::as_str) != Some(brain) {
2332 return Err(invalid_feed(
2333 "the home node served a card for a different brain",
2334 ));
2335 }
2336 let identity: FeedIdentity = serde_json::from_value(
2337 card.get("identity")
2338 .cloned()
2339 .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2340 )
2341 .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2342 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2343 let got_fp = card
2344 .get("identity")
2345 .and_then(|i| i.get("fingerprint"))
2346 .and_then(Value::as_str)
2347 .unwrap_or_default();
2348 if got_fp != want_fp {
2349 return Err(invalid_feed(
2350 "the home node served an identity that does not match the registry — refusing",
2351 ));
2352 }
2353 let current = format!("ed25519:{}", identity.fingerprint);
2354 let advertised_seq = card
2355 .get("headSeq")
2356 .and_then(Value::as_u64)
2357 .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2358 let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2359 if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2360 || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2361 {
2362 return Err(invalid_feed(
2363 "the home node served an invalid feed head boundary",
2364 ));
2365 }
2366 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2370 let registry_alias = AliasBinding {
2371 v: 1,
2372 origin: normalized_origin(&cfg.hub)?,
2373 requested: handle.to_string(),
2374 brain: brain.to_string(),
2375 home: Some(home.to_string()),
2376 };
2377 save_canonical_pin_and_alias(
2378 cfg,
2379 &trust_directory,
2380 handle,
2381 brain,
2382 TrustState {
2383 v: 2,
2384 origin: normalized_origin(&cfg.hub)?,
2385 requested: brain.to_string(),
2386 brain: brain.to_string(),
2387 home: None,
2388 anchor,
2389 current,
2390 head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2391 feed_hash: pinned
2392 .as_ref()
2393 .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2394 rotations: identity.rotations.clone(),
2395 hub_signer: None,
2396 protocol_profile: None,
2397 },
2398 Some(®istry_alias),
2399 )?;
2400 let mut out = card;
2401 if let Value::Object(map) = &mut out {
2402 map.insert("home".to_string(), Value::String(home.to_string()));
2403 map.insert(
2404 "resolvedVia".to_string(),
2405 Value::String("registry".to_string()),
2406 );
2407 }
2408 Ok(Some(out))
2409}
2410
2411pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2412 require_safe_ref(&addr.brain)?;
2416 if let Some(target) = &addr.target {
2417 let (given, ok) = match target {
2418 AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2419 AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2420 };
2421 if !ok {
2422 return Err(LinkError::BadAddress {
2423 given: given.clone(),
2424 reason: BAD_TARGET_REASON.to_string(),
2425 });
2426 }
2427 }
2428
2429 if let Some(target) = &addr.target {
2435 if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2436 let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2437 what: "resolve",
2438 status: 404,
2439 message: "record not found".to_string(),
2440 code: Some("NOT_FOUND".to_string()),
2441 details: None,
2442 })?;
2443 let (path, file) = match target {
2444 AddressTarget::Path(path) => {
2445 let file =
2446 v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2447 LinkError::Http {
2448 what: "resolve",
2449 status: 404,
2450 message: "record not found".to_string(),
2451 code: Some("NOT_FOUND".to_string()),
2452 details: None,
2453 }
2454 })?;
2455 (path.clone(), file)
2456 }
2457 AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2458 };
2459 let mut downloaded =
2460 download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2461 let (_, bytes) = downloaded
2462 .pop()
2463 .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2464 let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2465 accept_v2_head(cfg, &head)?;
2466 return Ok(resolved);
2467 }
2468 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2469 if !remote.head.verified {
2470 return Err(invalid_feed(
2471 "a path-scoped feed cannot prove a record against the full signed snapshot",
2472 ));
2473 }
2474 if remote.head.seq == 0 {
2475 return Err(LinkError::Http {
2476 what: "resolve",
2477 status: 404,
2478 message: "record not found".to_string(),
2479 code: Some("NOT_FOUND".to_string()),
2480 details: None,
2481 });
2482 }
2483 let brain = remote.head.brain.clone();
2484 let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2485 return resolve_from_verified_pack(&brain, target, pack);
2486 }
2487
2488 let path = format!("/api/hub/brains/{}", addr.brain);
2489 let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2494 if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2495 if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2496 return Ok(card);
2497 }
2498 }
2499 let mut resolved = ensure_ok(direct, "resolve")?;
2500 if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2501 let v2 = v2_verified_head(cfg, &addr.brain)?
2502 .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2503 if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2504 return Err(invalid_feed(
2505 "resolve card is not bound to the verified v2 brain",
2506 ));
2507 }
2508 let card_identity: FeedIdentity = serde_json::from_value(
2509 resolved
2510 .get("identity")
2511 .cloned()
2512 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2513 )
2514 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2515 if card_identity != v2_identity(&v2.identity) {
2516 return Err(invalid_feed(
2517 "resolve card identity differs from the verified v2 identity",
2518 ));
2519 }
2520 accept_v2_head(cfg, &v2)?;
2521 if let Value::Object(card) = &mut resolved {
2522 card.insert(
2523 "headSeq".to_string(),
2524 json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2525 );
2526 card.insert(
2527 "feedHash".to_string(),
2528 v2.pointer
2529 .as_ref()
2530 .map(|pointer| Value::String(pointer.feed_hash.clone()))
2531 .unwrap_or(Value::Null),
2532 );
2533 card.insert(
2534 "storageProfile".to_string(),
2535 Value::String("v2".to_string()),
2536 );
2537 if let Some(pointer) = &v2.pointer {
2538 card.insert(
2539 "updatedAt".to_string(),
2540 Value::String(pointer.signed_at.clone()),
2541 );
2542 }
2543 }
2544 return Ok(resolved);
2545 }
2546 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2550 if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2551 || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2552 || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2553 {
2554 return Err(invalid_feed(
2555 "resolve card is not bound to the exact verified feed checkpoint",
2556 ));
2557 }
2558 let card_identity: FeedIdentity = serde_json::from_value(
2559 resolved
2560 .get("identity")
2561 .cloned()
2562 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2563 )
2564 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2565 if remote.identity.as_ref() != Some(&card_identity) {
2566 return Err(invalid_feed(
2567 "resolve card identity differs from the verified feed identity",
2568 ));
2569 }
2570 Ok(resolved)
2571}
2572
2573fn resolve_from_verified_pack(
2578 brain: &str,
2579 target: &AddressTarget,
2580 pack: Vec<u8>,
2581) -> LinkResult<Value> {
2582 let entries = parse_store_pack(pack)?;
2583 let mut matched: Option<(String, Vec<u8>)> = None;
2584
2585 for (path, bytes) in entries {
2586 let is_candidate = match target {
2587 AddressTarget::Path(want) => &path == want,
2588 AddressTarget::Id(_) => {
2589 path.ends_with(".md")
2590 && (path.starts_with("records/") || path.starts_with("sources/"))
2591 }
2592 };
2593 if !is_candidate {
2594 continue;
2595 }
2596 let text = std::str::from_utf8(&bytes)
2597 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2598 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2599 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2600 if let AddressTarget::Id(want) = target {
2601 let frontmatter =
2602 crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2603 .map_err(|_| {
2604 invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2605 })?;
2606 if frontmatter.id.as_deref() != Some(want) {
2607 continue;
2608 }
2609 }
2610 if matched.is_some() {
2611 return Err(invalid_feed(
2612 "signed snapshot contains more than one record for the requested target",
2613 ));
2614 }
2615 matched = Some((path, bytes));
2616 }
2617
2618 let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2619 what: "resolve",
2620 status: 404,
2621 message: "record not found".to_string(),
2622 code: Some("NOT_FOUND".to_string()),
2623 details: None,
2624 })?;
2625 resolve_from_verified_record_bytes(brain, target, path, bytes)
2626}
2627
2628fn resolve_from_verified_record_bytes(
2629 brain: &str,
2630 target: &AddressTarget,
2631 path: String,
2632 bytes: Vec<u8>,
2633) -> LinkResult<Value> {
2634 match target {
2635 AddressTarget::Path(expected) if expected != &path => {
2636 return Err(invalid_feed(
2637 "verified record path differs from the requested path",
2638 ));
2639 }
2640 AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2641 return Err(invalid_feed(
2642 "verified id resolved outside records or sources",
2643 ));
2644 }
2645 _ => {}
2646 }
2647 let text = std::str::from_utf8(&bytes)
2648 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2649 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2650 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2651 let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2652 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2653 let Value::Object(fields) = frontmatter else {
2654 return Err(invalid_feed(format!(
2655 "signed snapshot record `{path}` frontmatter is not a mapping"
2656 )));
2657 };
2658 if let AddressTarget::Id(expected) = target {
2659 if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2660 return Err(invalid_feed(
2661 "verified record id differs from the requested id",
2662 ));
2663 }
2664 }
2665 let mut document = serde_json::Map::new();
2666 document.insert("path".to_string(), Value::String(path));
2667 for (key, value) in fields {
2668 document.insert(key, value);
2669 }
2670 document.insert("body".to_string(), Value::String(parsed.body));
2671 document.insert(
2672 "contentSha".to_string(),
2673 Value::String(content_sha256(&bytes)),
2674 );
2675 Ok(json!({
2676 "brain": brain,
2677 "document": Value::Object(document),
2678 }))
2679}
2680
2681#[derive(Debug, Clone, serde::Serialize)]
2687pub struct PullReport {
2688 pub brain: String,
2690 pub slug: String,
2692 #[serde(rename = "headSeq")]
2694 pub head_seq: u64,
2695 pub files: usize,
2697 pub dest: String,
2699 #[serde(rename = "extraLocal")]
2702 pub extra_local: Vec<String>,
2703 #[serde(rename = "syncStatus")]
2705 pub sync_status: String,
2706}
2707
2708struct V2PulledSnapshot {
2709 report: PullReport,
2710 head: V2VerifiedHead,
2711 files: std::collections::BTreeMap<String, V2BaselineFile>,
2712 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2713 local: V2LocalView,
2714 local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2715}
2716
2717fn download_verified_snapshot_pack(
2718 cfg: &HubConfig,
2719 brain: &str,
2720 remote: &VerifiedRemote,
2721) -> LinkResult<Vec<u8>> {
2722 let feed_hash = remote
2723 .head
2724 .feed_hash
2725 .as_deref()
2726 .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2727 let signed_head = remote
2728 .head_entry
2729 .as_ref()
2730 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2731 let expected = &signed_head.entry.pack_sha256;
2732 if !is_sha256(expected) {
2733 return Err(invalid_feed(
2734 "signed head carries an invalid snapshot pack digest",
2735 ));
2736 }
2737 let path = format!(
2738 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2739 remote.head.seq
2740 );
2741 let body = ensure_ok(
2742 request(cfg, "GET", &path, None, Auth::Required)?,
2743 "sync pull",
2744 )?;
2745 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2746 || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2747 || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2748 || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2749 {
2750 return Err(invalid_feed(
2751 "export response is not bound to the exact verified snapshot",
2752 ));
2753 }
2754 let url = body
2755 .get("url")
2756 .and_then(Value::as_str)
2757 .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2758 let bytes = get_presigned(cfg, url)?;
2759 if content_sha256(&bytes) != *expected {
2760 return Err(LinkError::InvalidPack {
2761 message: "downloaded pack does not match the signed snapshot digest".to_string(),
2762 });
2763 }
2764 let entries = parse_store_pack(bytes.clone())?;
2765 if signed_head.entry.kind == "push" {
2766 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2767 }
2768 Ok(bytes)
2769}
2770
2771#[derive(Debug, Clone, Deserialize, Serialize)]
2772struct V2PointerBody {
2773 v: u8,
2774 brain: String,
2775 seq: u64,
2776 commit_hash: String,
2777 feed_hash: String,
2778 content_root: Option<String>,
2779 asset_root: Option<String>,
2780 materializer: String,
2781 signer_epoch: u64,
2782 control_revision: String,
2783 backup_preparation: String,
2784 prior_pointer_hash: Option<String>,
2785 signed_at: String,
2786}
2787
2788#[derive(Debug, Clone, Deserialize)]
2789struct V2SignedPointer {
2790 pointer: V2PointerBody,
2791 hub_public_key: String,
2792 hub_fingerprint: String,
2793 sig: String,
2794}
2795
2796#[derive(Debug, Clone, Deserialize)]
2797struct V2HeadIdentity {
2798 #[serde(default)]
2799 custody: String,
2800 fingerprint: String,
2801 public_key_spki: String,
2802 #[serde(default)]
2803 previous: Vec<V2PreviousIdentity>,
2804 #[serde(default)]
2805 rotations: Vec<String>,
2806}
2807
2808#[derive(Debug, Clone, Deserialize)]
2809struct V2PreviousIdentity {
2810 fingerprint: String,
2811 public_key_spki: String,
2812}
2813
2814#[derive(Debug, Deserialize)]
2815struct V2HeadResponse {
2816 v: u8,
2817 brain_id: String,
2818 profile: String,
2819 view: Option<V2HeadView>,
2820 pointer: Option<V2SignedPointer>,
2821 identity: Option<V2HeadIdentity>,
2822}
2823
2824#[derive(Debug, Clone, Deserialize)]
2825struct V2HeadView {
2826 kind: String,
2827 #[serde(default)]
2828 id: Option<String>,
2829 control_revision: String,
2830}
2831
2832#[derive(Debug, Clone)]
2833struct V2VerifiedHead {
2834 requested: String,
2835 brain_id: String,
2836 view_kind: String,
2837 view_revision: String,
2839 control_revision: String,
2841 identity: V2HeadIdentity,
2842 pointer: Option<V2PointerBody>,
2843 trust: TrustState,
2844 alias: Option<AliasBinding>,
2845}
2846
2847fn verify_v2_spki_signature(
2848 public_key: &str,
2849 message: &[u8],
2850 signature: &str,
2851) -> LinkResult<Vec<u8>> {
2852 let der = URL_SAFE_NO_PAD
2853 .decode(public_key)
2854 .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2855 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2856 return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2857 }
2858 let sig = URL_SAFE_NO_PAD
2859 .decode(signature)
2860 .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2861 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2862 .verify(message, &sig)
2863 .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2864 Ok(der)
2865}
2866
2867fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2868 if pointer.pointer.v != 2
2869 || pointer.pointer.brain != expected_brain
2870 || pointer.pointer.seq == 0
2871 || !is_sha256(&pointer.pointer.commit_hash)
2872 || !is_sha256(&pointer.pointer.feed_hash)
2873 || pointer
2874 .pointer
2875 .content_root
2876 .as_deref()
2877 .is_some_and(|hash| !is_sha256(hash))
2878 || !is_sha256(&pointer.pointer.backup_preparation)
2879 {
2880 return Err(invalid_feed("v2 pointer fields are invalid"));
2881 }
2882 let value = serde_json::to_value(&pointer.pointer)
2883 .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2884 let message = crate::linkmd_v2::canonical_bytes(&value)
2885 .map_err(|error| invalid_feed(error.to_string()))?;
2886 let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2887 let fingerprint = format!("{:x}", Sha256::digest(&der));
2888 if fingerprint != pointer.hub_fingerprint {
2889 return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2890 }
2891 Ok(format!(
2892 "{}:{}",
2893 pointer.hub_fingerprint, pointer.hub_public_key
2894 ))
2895}
2896
2897fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2898 FeedIdentity {
2899 fingerprint: identity.fingerprint.clone(),
2900 public_key_spki: identity.public_key_spki.clone(),
2901 previous: identity
2902 .previous
2903 .iter()
2904 .map(|previous| PreviousIdentity {
2905 fingerprint: previous.fingerprint.clone(),
2906 public_key_spki: previous.public_key_spki.clone(),
2907 })
2908 .collect(),
2909 rotations: identity.rotations.clone(),
2910 }
2911}
2912
2913fn verified_v2_commit_object(
2914 raw: &[u8],
2915 identity: &V2HeadIdentity,
2916) -> LinkResult<serde_json::Map<String, Value>> {
2917 let mut value: Value =
2918 serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2919 let canonical = crate::linkmd_v2::canonical_bytes(&value)
2920 .map_err(|error| invalid_feed(error.to_string()))?;
2921 if canonical != raw {
2922 return Err(invalid_feed("v2 commit is not canonical JSON"));
2923 }
2924 let object = value
2925 .as_object_mut()
2926 .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
2927 let sig = object
2928 .remove("sig")
2929 .and_then(|value| value.as_str().map(str::to_string))
2930 .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
2931 const FIELDS: [&str; 18] = [
2932 "actor_ref",
2933 "asset_root",
2934 "brain",
2935 "changes_sha256",
2936 "control_revision",
2937 "materializer",
2938 "op",
2939 "parent_asset_root",
2940 "parent_commit",
2941 "parent_root",
2942 "prev_entry_hash",
2943 "public_key",
2944 "seq",
2945 "signer_epoch",
2946 "state_root",
2947 "ts",
2948 "v",
2949 "v1_bridge",
2950 ];
2951 if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
2952 return Err(invalid_feed("v2 commit has a non-normative field set"));
2953 }
2954 let seq = object
2955 .get("seq")
2956 .and_then(Value::as_u64)
2957 .filter(|seq| *seq > 0)
2958 .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
2959 let signer_epoch = object
2960 .get("signer_epoch")
2961 .and_then(Value::as_u64)
2962 .filter(|epoch| *epoch > 0)
2963 .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
2964 let hash_or_null = |field: &str| {
2965 object
2966 .get(field)
2967 .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
2968 };
2969 if object.get("v").and_then(Value::as_u64) != Some(2)
2970 || object.get("op").and_then(Value::as_str) != Some("changeset")
2971 || !object
2972 .get("changes_sha256")
2973 .and_then(Value::as_str)
2974 .is_some_and(is_sha256)
2975 || !object
2976 .get("actor_ref")
2977 .and_then(Value::as_str)
2978 .is_some_and(is_sha256)
2979 || !object
2980 .get("control_revision")
2981 .and_then(Value::as_str)
2982 .is_some_and(is_sha256)
2983 || !object
2984 .get("state_root")
2985 .and_then(Value::as_str)
2986 .is_some_and(is_sha256)
2987 || !hash_or_null("parent_commit")
2988 || !hash_or_null("parent_root")
2989 || !hash_or_null("parent_asset_root")
2990 || !hash_or_null("asset_root")
2991 || !hash_or_null("prev_entry_hash")
2992 || !object
2993 .get("materializer")
2994 .and_then(Value::as_str)
2995 .is_some_and(|value| !value.is_empty() && value.len() <= 128)
2996 || !object
2997 .get("ts")
2998 .and_then(Value::as_str)
2999 .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
3000 {
3001 return Err(invalid_feed("v2 commit fields are invalid"));
3002 }
3003 if (seq == 1
3004 && [
3005 "parent_commit",
3006 "parent_root",
3007 "parent_asset_root",
3008 "prev_entry_hash",
3009 ]
3010 .iter()
3011 .any(|field| !object.get(*field).is_some_and(Value::is_null)))
3012 || (seq > 1
3013 && ["parent_commit", "parent_root", "prev_entry_hash"]
3014 .iter()
3015 .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
3016 {
3017 return Err(invalid_feed("v2 commit parent shape is invalid"));
3018 }
3019 match object.get("v1_bridge") {
3020 Some(Value::Null) => {}
3021 Some(Value::Object(bridge))
3022 if seq == 1
3023 && bridge.len() == 3
3024 && bridge
3025 .get("head_seq")
3026 .and_then(Value::as_u64)
3027 .is_some_and(|v| v > 0)
3028 && bridge
3029 .get("feed_hash")
3030 .and_then(Value::as_str)
3031 .is_some_and(is_sha256)
3032 && bridge
3033 .get("pack_sha256")
3034 .and_then(Value::as_str)
3035 .is_some_and(is_sha256) => {}
3036 _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
3037 }
3038 let public_key = object
3039 .get("public_key")
3040 .and_then(Value::as_str)
3041 .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3042 let der = URL_SAFE_NO_PAD
3043 .decode(public_key)
3044 .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3045 let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3046 if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3047 return Err(invalid_feed("v2 commit brain identity mismatch"));
3048 }
3049 verify_identity_chain(&v2_identity(identity), None)?;
3051 let mut chain: Vec<(&str, &str)> = identity
3054 .previous
3055 .iter()
3056 .rev()
3057 .map(|previous| {
3058 (
3059 previous.fingerprint.as_str(),
3060 previous.public_key_spki.as_str(),
3061 )
3062 })
3063 .collect();
3064 chain.push((&identity.fingerprint, &identity.public_key_spki));
3065 let signer_index = chain.iter().position(|(fingerprint, spki)| {
3066 *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3067 });
3068 let Some(signer_index) = signer_index else {
3069 return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3070 };
3071 if signer_epoch != signer_index as u64 + 1 {
3072 return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3073 }
3074 let lower_boundary = if signer_index == 0 {
3075 None
3076 } else {
3077 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3078 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3079 Some(prior.prior_head_seq)
3080 };
3081 let upper_boundary = if signer_index == identity.rotations.len() {
3082 None
3083 } else {
3084 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3085 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3086 Some(next.prior_head_seq)
3087 };
3088 if lower_boundary.is_some_and(|boundary| seq <= boundary)
3089 || upper_boundary.is_some_and(|boundary| seq > boundary)
3090 {
3091 return Err(invalid_feed(
3092 "v2 commit signer is outside its authenticated rotation epoch",
3093 ));
3094 }
3095 let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3096 .map_err(|error| invalid_feed(error.to_string()))?;
3097 verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3098 Ok(object.clone())
3099}
3100
3101#[derive(Debug, Deserialize)]
3102struct V2FeedWireEntry {
3103 seq: u64,
3104 commit_hash: String,
3105 feed_hash: String,
3106 bytes_base64: String,
3107}
3108
3109#[derive(Debug, Deserialize)]
3110struct V2FeedPage {
3111 v: u8,
3112 head_seq: u64,
3113 head_commit_hash: String,
3114 head_feed_hash: String,
3115 entries: Vec<V2FeedWireEntry>,
3116 next_after: u64,
3117 complete: bool,
3118}
3119
3120fn replay_v2_feed(
3121 cfg: &HubConfig,
3122 brain: &str,
3123 pointer: &V2PointerBody,
3124 identity: &V2HeadIdentity,
3125 start_after: u64,
3126 start_feed: Option<String>,
3127) -> LinkResult<()> {
3128 let mut after = start_after;
3129 let mut prior_feed = start_feed;
3130 let mut final_object = None;
3131 let mut replayed_entries = 0_u64;
3132 let mut replayed_bytes = 0_u64;
3133 while after < pointer.seq {
3134 let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3135 let value = ensure_ok(
3136 request_capped(
3137 cfg,
3138 "GET",
3139 &path,
3140 None,
3141 Auth::Required,
3142 MAX_FEED_REPLAY_BYTES,
3143 )?,
3144 "v2 feed replay",
3145 )?;
3146 let page: V2FeedPage = serde_json::from_value(value)
3147 .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3148 if page.v != 2
3149 || page.head_seq != pointer.seq
3150 || page.head_commit_hash != pointer.commit_hash
3151 || page.head_feed_hash != pointer.feed_hash
3152 || page.entries.is_empty()
3153 || page.entries.len() > FEED_PAGE_LIMIT
3154 {
3155 return Err(invalid_feed("v2 feed page differs from the signed head"));
3156 }
3157 for entry in page.entries {
3158 if entry.seq != after + 1
3159 || !is_sha256(&entry.commit_hash)
3160 || !is_sha256(&entry.feed_hash)
3161 {
3162 return Err(invalid_feed("v2 feed sequence is not contiguous"));
3163 }
3164 let raw = base64::engine::general_purpose::STANDARD
3165 .decode(&entry.bytes_base64)
3166 .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3167 replayed_entries = replayed_entries
3168 .checked_add(1)
3169 .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3170 replayed_bytes = replayed_bytes
3171 .checked_add(raw.len() as u64)
3172 .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3173 if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3174 {
3175 return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3176 }
3177 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3178 .map_err(|error| invalid_feed(error.to_string()))?
3179 != entry.commit_hash
3180 || content_sha256(&raw) != entry.feed_hash
3181 {
3182 return Err(invalid_feed("v2 feed entry address mismatch"));
3183 }
3184 let object = verified_v2_commit_object(&raw, identity)?;
3185 if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3186 || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3187 {
3188 return Err(invalid_feed(
3189 "v2 feed entry does not extend its predecessor",
3190 ));
3191 }
3192 after = entry.seq;
3193 prior_feed = Some(entry.feed_hash);
3194 final_object = Some((entry.commit_hash, object));
3195 }
3196 if page.next_after != after || (page.complete != (after == pointer.seq)) {
3197 return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3198 }
3199 }
3200 let (final_hash, object) =
3201 final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3202 if final_hash != pointer.commit_hash
3203 || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3204 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3205 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3206 || object.get("control_revision").and_then(Value::as_str)
3207 != Some(pointer.control_revision.as_str())
3208 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3209 {
3210 return Err(invalid_feed(
3211 "v2 replay did not converge on the signed pointer",
3212 ));
3213 }
3214 Ok(())
3215}
3216
3217fn verify_v1_to_v2_bridge(
3218 cfg: &HubConfig,
3219 brain: &str,
3220 pointer: &V2PointerBody,
3221 identity: &V2HeadIdentity,
3222 checkpoint: &TrustState,
3223) -> LinkResult<()> {
3224 let value = ensure_ok(
3225 request_capped(
3226 cfg,
3227 "GET",
3228 &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3229 None,
3230 Auth::Required,
3231 MAX_FEED_RESPONSE_BYTES,
3232 )?,
3233 "v2 genesis bridge",
3234 )?;
3235 let page: V2FeedPage = serde_json::from_value(value)
3236 .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3237 if page.v != 2
3238 || page.head_seq != pointer.seq
3239 || page.head_commit_hash != pointer.commit_hash
3240 || page.head_feed_hash != pointer.feed_hash
3241 || page.entries.len() != 1
3242 || page.entries[0].seq != 1
3243 || !is_sha256(&page.entries[0].commit_hash)
3244 || !is_sha256(&page.entries[0].feed_hash)
3245 {
3246 return Err(invalid_feed(
3247 "v2 genesis bridge page differs from the signed head",
3248 ));
3249 }
3250 let first = &page.entries[0];
3251 let raw = STANDARD
3252 .decode(&first.bytes_base64)
3253 .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3254 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3255 .map_err(|error| invalid_feed(error.to_string()))?
3256 != first.commit_hash
3257 || content_sha256(&raw) != first.feed_hash
3258 {
3259 return Err(invalid_feed("v2 genesis bridge address mismatch"));
3260 }
3261 let object = verified_v2_commit_object(&raw, identity)?;
3262 if checkpoint.head_seq == 0 {
3263 if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3264 return Err(invalid_feed(
3265 "empty v1 checkpoint did not transition through an empty v2 genesis",
3266 ));
3267 }
3268 return Ok(());
3269 }
3270 let bridge = object
3271 .get("v1_bridge")
3272 .and_then(Value::as_object)
3273 .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3274 let checkpoint_feed = checkpoint
3275 .feed_hash
3276 .as_deref()
3277 .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3278 if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3279 || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3280 {
3281 return Err(invalid_feed(
3282 "v2 genesis bridge differs from the pinned v1 checkpoint",
3283 ));
3284 }
3285 let legacy_raw = ensure_raw_ok(
3286 request_raw(
3287 cfg,
3288 "GET",
3289 &format!(
3290 "/api/hub/brains/{brain}/feed?after={}&limit=1",
3291 checkpoint.head_seq - 1
3292 ),
3293 None,
3294 Auth::Required,
3295 MAX_FEED_RESPONSE_BYTES,
3296 )?,
3297 "v1 bridge boundary",
3298 )?;
3299 let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3300 .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3301 let legacy_identity = legacy
3302 .identity
3303 .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3304 let item = legacy
3305 .entries
3306 .first()
3307 .filter(|_| legacy.entries.len() == 1)
3308 .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3309 if legacy.scope_limited
3310 || legacy.head_seq != checkpoint.head_seq
3311 || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3312 || item.entry.seq != checkpoint.head_seq
3313 || item.hash != checkpoint_feed
3314 || legacy_identity != v2_identity(identity)
3315 || bridge.get("pack_sha256").and_then(Value::as_str)
3316 != Some(item.entry.pack_sha256.as_str())
3317 {
3318 return Err(invalid_feed(
3319 "v1 bridge boundary differs from its signed legacy head",
3320 ));
3321 }
3322 let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3323 if anchor != checkpoint.anchor {
3324 return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3325 }
3326 verify_feed_item(item, &legacy_identity)?;
3327 verify_rotation_feed_boundaries(
3328 &legacy_identity,
3329 Some(checkpoint),
3330 std::slice::from_ref(item),
3331 checkpoint.head_seq,
3332 )?;
3333 Ok(())
3334}
3335
3336fn verify_v2_commit(
3337 cfg: &HubConfig,
3338 brain: &str,
3339 pointer: &V2PointerBody,
3340 identity: &V2HeadIdentity,
3341 pinned: Option<&TrustState>,
3342) -> LinkResult<()> {
3343 let path = format!(
3344 "/api/hub/brains/{brain}/v2/commit?commit={}",
3345 pointer.commit_hash
3346 );
3347 let raw = ensure_raw_ok(
3348 request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3349 "v2 commit",
3350 )?;
3351 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3352 .map_err(|error| invalid_feed(error.to_string()))?
3353 != pointer.commit_hash
3354 || content_sha256(&raw) != pointer.feed_hash
3355 {
3356 return Err(invalid_feed("v2 commit address differs from the pointer"));
3357 }
3358 let object = verified_v2_commit_object(&raw, identity)?;
3359 if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3360 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3361 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3362 || object.get("control_revision").and_then(Value::as_str)
3363 != Some(pointer.control_revision.as_str())
3364 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3365 {
3366 return Err(invalid_feed("v2 commit fields differ from the pointer"));
3367 }
3368 if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3369 if pointer.seq == checkpoint.head_seq + 1
3370 && object.get("prev_entry_hash").and_then(Value::as_str)
3371 != checkpoint.feed_hash.as_deref()
3372 {
3373 return Err(invalid_feed(
3374 "v2 commit does not extend the pinned feed hash",
3375 ));
3376 }
3377 if pointer.seq > checkpoint.head_seq + 1 {
3378 return replay_v2_feed(
3379 cfg,
3380 brain,
3381 pointer,
3382 identity,
3383 checkpoint.head_seq,
3384 checkpoint.feed_hash.clone(),
3385 );
3386 }
3387 } else {
3388 if let Some(checkpoint) = pinned {
3389 verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3390 }
3391 if pointer.seq > 1 {
3392 return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3393 }
3394 }
3395 Ok(())
3396}
3397
3398fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3399 require_hardened_filesystem("verified link.md v2 state")?;
3400 require_safe_ref(brain)?;
3401 let trust_directory = open_trust_dir(cfg)?;
3405 let path = format!("/api/hub/brains/{brain}/v2/head");
3406 let response = request(cfg, "GET", &path, None, Auth::Required)?;
3407 if response.status == 404 {
3408 if has_accepted_v2_ref(cfg, brain)? {
3409 return Err(LinkError::BrainUnavailable);
3410 }
3411 return Ok(None);
3412 }
3413 let body = ensure_ok(response, "v2 head")?;
3414 let head: V2HeadResponse = serde_json::from_value(body)
3415 .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3416 if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3417 return Err(invalid_feed("v2 head has no canonical brain id"));
3418 }
3419 if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3420 return Err(invalid_feed("v2 head resolved a different brain id"));
3421 }
3422 if head.profile == "v1" {
3423 return Ok(None);
3424 }
3425 if head.profile != "v2" && head.profile != "v2-empty" {
3426 return Err(invalid_feed("v2 head advertised an unknown profile"));
3427 }
3428 let view = head
3429 .view
3430 .as_ref()
3431 .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3432 if !matches!(view.kind.as_str(), "full" | "scoped")
3433 || !is_sha256(&view.control_revision)
3434 || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3435 {
3436 return Err(invalid_feed("v2 head has an invalid permission view"));
3437 }
3438 let view_kind = view.kind.clone();
3439 let view_revision = view
3442 .id
3443 .clone()
3444 .unwrap_or_else(|| view.control_revision.clone());
3445 let control_revision = view.control_revision.clone();
3446 let identity = head
3447 .identity
3448 .as_ref()
3449 .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3450 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3451 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3452 let feed_identity = v2_identity(identity);
3453 let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3454 let (seq, feed_hash, hub_signer) = match &head.pointer {
3455 None => {
3456 if head.profile != "v2-empty" {
3457 return Err(invalid_feed("initialized v2 head has no pointer"));
3458 }
3459 (
3460 0,
3461 None,
3462 pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3463 )
3464 }
3465 Some(signed) => {
3466 let signer = verify_v2_pointer(signed, &head.brain_id)?;
3467 if pinned
3468 .as_ref()
3469 .and_then(|state| state.hub_signer.as_ref())
3470 .is_some_and(|known| known != &signer)
3471 {
3472 return Err(invalid_feed(
3473 "v2 hub pointer signer changed without a trust transition",
3474 ));
3475 }
3476 if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3477 if signed.pointer.seq < checkpoint.head_seq
3478 || (signed.pointer.seq == checkpoint.head_seq
3479 && checkpoint.feed_hash.as_deref()
3480 != Some(signed.pointer.feed_hash.as_str()))
3481 {
3482 return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3483 }
3484 }
3485 verify_v2_commit(
3486 cfg,
3487 &head.brain_id,
3488 &signed.pointer,
3489 identity,
3490 pinned.as_ref(),
3491 )?;
3492 (
3493 signed.pointer.seq,
3494 Some(signed.pointer.feed_hash.clone()),
3495 Some(signer),
3496 )
3497 }
3498 };
3499 let trust = TrustState {
3500 v: 2,
3501 origin: normalized_origin(&cfg.hub)?,
3502 requested: head.brain_id.clone(),
3503 brain: head.brain_id.clone(),
3504 home: None,
3505 anchor,
3506 current: format!("ed25519:{}", identity.fingerprint),
3507 head_seq: seq,
3508 feed_hash,
3509 rotations: identity.rotations.clone(),
3510 hub_signer,
3511 protocol_profile: Some("link-v2".to_string()),
3512 };
3513 Ok(Some(V2VerifiedHead {
3514 requested: brain.to_string(),
3515 brain_id: head.brain_id,
3516 view_kind,
3517 view_revision,
3518 control_revision,
3519 identity: identity.clone(),
3520 pointer: head.pointer.map(|signed| signed.pointer),
3521 trust,
3522 alias: alias_binding,
3523 }))
3524}
3525
3526fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3527 let directory = open_trust_dir(cfg)?;
3528 let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3529 let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3530 if let Some(current) = current {
3531 let common_invalid = head.trust.anchor != current.anchor
3532 || !head.trust.rotations.starts_with(¤t.rotations);
3533 let profile_invalid = if accepted_as_v2(¤t) {
3534 head.trust.head_seq < current.head_seq
3535 || (head.trust.head_seq == current.head_seq
3536 && head.trust.feed_hash != current.feed_hash)
3537 || current
3538 .hub_signer
3539 .as_ref()
3540 .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3541 } else {
3542 head.trust.protocol_profile.as_deref() != Some("link-v2")
3543 || head.trust.hub_signer.is_none()
3544 };
3545 if common_invalid || profile_invalid {
3546 return Err(invalid_feed(
3547 "v2 head cannot advance the currently accepted trust checkpoint",
3548 ));
3549 }
3550 }
3551 save_canonical_pin_and_alias(
3552 cfg,
3553 &directory,
3554 &head.requested,
3555 &head.brain_id,
3556 head.trust.clone(),
3557 alias.as_ref().or(head.alias.as_ref()),
3558 )
3559}
3560
3561#[derive(Debug, Clone, Deserialize, Serialize)]
3562struct V2BaselineFile {
3563 sha256: String,
3564 bytes: u64,
3565 #[serde(skip)]
3566 proof: Option<Vec<V2ProofStep>>,
3567}
3568
3569#[derive(Debug, Clone, Deserialize, Serialize)]
3570struct V2SyncBaseline {
3571 v: u8,
3572 origin: String,
3573 brain: String,
3574 #[serde(default)]
3575 checkout_id: Option<String>,
3576 #[serde(default)]
3577 head_seq: Option<u64>,
3578 commit_hash: Option<String>,
3579 content_root: Option<String>,
3580 #[serde(default)]
3581 asset_root: Option<String>,
3582 #[serde(default)]
3583 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3584 #[serde(default)]
3585 view_kind: Option<String>,
3586 #[serde(default)]
3587 view_revision: Option<String>,
3588 #[serde(default)]
3589 projection_sha256: Option<String>,
3590 files: std::collections::BTreeMap<String, V2BaselineFile>,
3591 #[serde(default)]
3592 local_policy_digest: Option<String>,
3593 #[serde(default)]
3594 local_eligibility: std::collections::BTreeMap<String, bool>,
3595 #[serde(default)]
3596 remote_copy_remains: std::collections::BTreeMap<String, String>,
3597}
3598
3599struct V2LocalView {
3600 riding: std::collections::BTreeMap<String, (String, u64)>,
3601 eligibility: std::collections::BTreeMap<String, bool>,
3602 policy: crate::linkmd_sync_policy::SyncPolicy,
3603 withheld_links: Vec<V2WithheldLink>,
3604}
3605
3606#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3607struct V2WithheldLink {
3608 source: String,
3609 target: String,
3610}
3611
3612#[derive(Debug, Clone, Deserialize, Serialize)]
3613struct V2ProofStep {
3614 directory_root: String,
3615 component: String,
3616 proof: crate::linkmd_v2::HamtProof,
3617}
3618
3619#[derive(Debug, Deserialize)]
3620struct V2ManifestFile {
3621 path: String,
3622 sha256: String,
3623 bytes: u64,
3624 proof: Vec<V2ProofStep>,
3625}
3626
3627#[derive(Debug, Deserialize)]
3628struct V2ManifestPage {
3629 v: u8,
3630 commit: String,
3631 content_root: Option<String>,
3632 files: Vec<V2ManifestFile>,
3633 next_cursor: Option<String>,
3634}
3635
3636#[derive(Debug, Clone, Deserialize, Serialize)]
3637struct V2BaselineAsset {
3638 blob_sha256: String,
3639 bytes: u64,
3640 media_type: String,
3641 wrappers: Vec<String>,
3642 required: bool,
3643 disposition: String,
3644 leaf_hash: String,
3645}
3646
3647#[derive(Debug, Deserialize)]
3648struct V2AssetManifestItem {
3649 path: String,
3650 blob_sha256: String,
3651 bytes: u64,
3652 media_type: String,
3653 wrappers: Vec<String>,
3654 required: bool,
3655 disposition: String,
3656 leaf_hash: String,
3657 proof: crate::linkmd_v2::HamtProof,
3658}
3659
3660#[derive(Debug, Deserialize)]
3661struct V2AssetManifestPage {
3662 v: u8,
3663 commit: String,
3664 asset_root: Option<String>,
3665 assets: Vec<V2AssetManifestItem>,
3666 next_cursor: Option<String>,
3667}
3668
3669#[derive(Debug, Deserialize)]
3670struct V2SigningCandidate {
3671 seq: u64,
3672 content_root: Option<String>,
3673 asset_root: Option<String>,
3674 signing_bytes_base64: String,
3675 changes_base64: String,
3676 actor_claim_base64: String,
3677}
3678
3679#[derive(Debug, Deserialize)]
3680struct V2SigningCandidatePage {
3681 v: u8,
3682 challenge_id: String,
3683 mutation_id: String,
3684 request_hash: String,
3685 parent: V2SigningParent,
3686 candidate: V2SigningCandidate,
3687 files: Vec<V2ManifestFile>,
3688 #[serde(default)]
3689 assets: Vec<V2AssetManifestItem>,
3690 next_cursor: Option<String>,
3691 expires_at: String,
3692}
3693
3694#[derive(Debug, Deserialize)]
3695struct V2SigningParent {
3696 seq: u64,
3697 commit_hash: Option<String>,
3698}
3699
3700fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3701 let normalized = crate::linkmd_v2::normalize_path(&file.path)
3702 .map_err(|error| invalid_feed(error.to_string()))?;
3703 let components = normalized.split('/').collect::<Vec<_>>();
3704 if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3705 return Err(invalid_feed("v2 file proof has the wrong shape"));
3706 }
3707 let mut directory_root = root.to_string();
3708 for (index, step) in file.proof.iter().enumerate() {
3709 if step.directory_root != directory_root || step.component != components[index] {
3710 return Err(invalid_feed(
3711 "v2 file proof path chain differs from its manifest",
3712 ));
3713 }
3714 if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3715 .map_err(|error| invalid_feed(error.to_string()))?
3716 {
3717 return Err(invalid_feed("v2 file proof failed verification"));
3718 }
3719 let entry = match &step.proof {
3720 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3721 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3722 return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3723 }
3724 };
3725 if index + 1 == components.len() {
3726 if entry.kind != crate::linkmd_v2::EntryKind::Blob
3727 || entry.child_hash != file.sha256
3728 || entry.bytes != Some(file.bytes)
3729 {
3730 return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3731 }
3732 } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3733 return Err(invalid_feed("v2 file proof traversed a non-directory"));
3734 } else {
3735 directory_root = entry.child_hash.clone();
3736 }
3737 }
3738 Ok(())
3739}
3740
3741fn v2_manifest(
3742 cfg: &HubConfig,
3743 brain: &str,
3744 pointer: Option<&V2PointerBody>,
3745) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3746 let Some(pointer) = pointer else {
3747 return Ok(std::collections::BTreeMap::new());
3748 };
3749 let Some(root) = pointer.content_root.as_deref() else {
3750 return Ok(std::collections::BTreeMap::new());
3751 };
3752 let mut files = std::collections::BTreeMap::new();
3753 let mut after = String::new();
3754 loop {
3755 let encoded_after: String =
3756 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3757 let path = format!(
3758 "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3759 pointer.commit_hash
3760 );
3761 let value = ensure_ok(
3762 request_capped(
3763 cfg,
3764 "GET",
3765 &path,
3766 None,
3767 Auth::Required,
3768 MAX_FEED_RESPONSE_BYTES,
3769 )?,
3770 "v2 file manifest",
3771 )?;
3772 let page: V2ManifestPage = serde_json::from_value(value)
3773 .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3774 if page.v != 2
3775 || page.commit != pointer.commit_hash
3776 || page.content_root.as_deref() != Some(root)
3777 || page.files.len() > 500
3778 {
3779 return Err(invalid_feed(
3780 "v2 file manifest is not bound to the verified head",
3781 ));
3782 }
3783 for file in page.files {
3784 verify_v2_file_proof(root, &file)?;
3785 if files
3786 .insert(
3787 file.path.clone(),
3788 V2BaselineFile {
3789 sha256: file.sha256,
3790 bytes: file.bytes,
3791 proof: Some(file.proof),
3792 },
3793 )
3794 .is_some()
3795 {
3796 return Err(invalid_feed("v2 file manifest repeats a path"));
3797 }
3798 if files.len() > MAX_PUSH_FILES {
3799 return Err(invalid_feed(
3800 "v2 file manifest exceeds the file-count bound",
3801 ));
3802 }
3803 }
3804 match page.next_cursor {
3805 None => break,
3806 Some(next) if next > after => after = next,
3807 Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3808 }
3809 }
3810 Ok(files)
3811}
3812
3813fn v2_manifest_file(
3818 cfg: &HubConfig,
3819 brain: &str,
3820 pointer: &V2PointerBody,
3821 path: &str,
3822) -> LinkResult<Option<V2BaselineFile>> {
3823 let Some(root) = pointer.content_root.as_deref() else {
3824 return Ok(None);
3825 };
3826 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
3827 path: error.to_string(),
3828 })?;
3829 let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
3830 let response = request_capped(
3831 cfg,
3832 "GET",
3833 &format!(
3834 "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
3835 pointer.commit_hash
3836 ),
3837 None,
3838 Auth::Required,
3839 MAX_FEED_RESPONSE_BYTES,
3840 )?;
3841 if response.status == 404 {
3845 return Ok(None);
3846 }
3847 let value = ensure_ok(response, "v2 exact file proof")?;
3848 let mut page: V2ManifestPage = serde_json::from_value(value)
3849 .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
3850 if page.v != 2
3851 || page.commit != pointer.commit_hash
3852 || page.content_root.as_deref() != Some(root)
3853 || page.next_cursor.is_some()
3854 || page.files.len() != 1
3855 || page.files[0].path != path
3856 {
3857 return Err(invalid_feed(
3858 "v2 exact file proof is not bound to the requested signed path",
3859 ));
3860 }
3861 let file = page.files.pop().expect("exactly one file was checked");
3862 verify_v2_file_proof(root, &file)?;
3863 Ok(Some(V2BaselineFile {
3864 sha256: file.sha256,
3865 bytes: file.bytes,
3866 proof: Some(file.proof),
3867 }))
3868}
3869
3870fn v2_manifest_file_by_id(
3875 cfg: &HubConfig,
3876 brain: &str,
3877 pointer: &V2PointerBody,
3878 id: &str,
3879) -> LinkResult<(String, V2BaselineFile)> {
3880 let root = pointer
3881 .content_root
3882 .as_deref()
3883 .ok_or_else(|| LinkError::Http {
3884 what: "resolve",
3885 status: 404,
3886 message: "record not found".to_string(),
3887 code: Some("NOT_FOUND".to_string()),
3888 details: None,
3889 })?;
3890 if !crate::ulid::is_ulid(id) {
3891 return Err(LinkError::BadAddress {
3892 given: id.to_string(),
3893 reason: BAD_TARGET_REASON.to_string(),
3894 });
3895 }
3896 let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
3897 let value = ensure_ok(
3898 request_capped(
3899 cfg,
3900 "GET",
3901 &format!(
3902 "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
3903 pointer.commit_hash
3904 ),
3905 None,
3906 Auth::Required,
3907 MAX_FEED_RESPONSE_BYTES,
3908 )?,
3909 "v2 exact id proof",
3910 )?;
3911 let mut page: V2ManifestPage = serde_json::from_value(value)
3912 .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
3913 if page.v != 2
3914 || page.commit != pointer.commit_hash
3915 || page.content_root.as_deref() != Some(root)
3916 || page.next_cursor.is_some()
3917 || page.files.len() != 1
3918 {
3919 return Err(invalid_feed(
3920 "v2 exact id proof is not bound to one signed path",
3921 ));
3922 }
3923 let file = page.files.pop().expect("exactly one file was checked");
3924 if !safe_store_rel_path(&file.path)
3925 || !file.path.ends_with(".md")
3926 || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
3927 {
3928 return Err(invalid_feed("v2 exact id proof has an invalid record path"));
3929 }
3930 verify_v2_file_proof(root, &file)?;
3931 Ok((
3932 file.path,
3933 V2BaselineFile {
3934 sha256: file.sha256,
3935 bytes: file.bytes,
3936 proof: Some(file.proof),
3937 },
3938 ))
3939}
3940
3941fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
3942 crate::linkmd_v2::normalize_path(&item.path)
3943 .map_err(|error| invalid_feed(error.to_string()))?;
3944 if !is_sha256(&item.blob_sha256)
3945 || !is_sha256(&item.leaf_hash)
3946 || item.bytes > MAX_ASSET_BYTES
3947 || item.wrappers.is_empty()
3948 || !matches!(item.disposition.as_str(), "hosted" | "withheld")
3949 || item
3950 .wrappers
3951 .iter()
3952 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
3953 {
3954 return Err(invalid_feed("v2 asset manifest item is invalid"));
3955 }
3956 let leaf = json!({
3957 "blob_sha256": item.blob_sha256,
3958 "bytes": item.bytes,
3959 "disposition": item.disposition,
3960 "media_type": item.media_type,
3961 "path": item.path,
3962 "required": item.required,
3963 "v": 2,
3964 "wrappers": item.wrappers,
3965 });
3966 if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
3967 .map_err(|error| invalid_feed(error.to_string()))?
3968 != item.leaf_hash
3969 || !crate::linkmd_v2::verify_proof_with_domain(
3970 root,
3971 &item.path,
3972 &item.proof,
3973 crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
3974 )
3975 .map_err(|error| invalid_feed(error.to_string()))?
3976 {
3977 return Err(invalid_feed("v2 asset inclusion proof failed"));
3978 }
3979 match &item.proof {
3980 crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
3981 if entry.name == item.path
3982 && entry.kind == crate::linkmd_v2::EntryKind::Blob
3983 && entry.child_hash == item.leaf_hash
3984 && entry.bytes == Some(item.bytes) =>
3985 {
3986 Ok(())
3987 }
3988 _ => Err(invalid_feed(
3989 "v2 asset proof leaf differs from its manifest",
3990 )),
3991 }
3992}
3993
3994fn v2_asset_manifest(
3995 cfg: &HubConfig,
3996 brain: &str,
3997 pointer: Option<&V2PointerBody>,
3998) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
3999 let Some(pointer) = pointer else {
4000 return Ok(std::collections::BTreeMap::new());
4001 };
4002 let Some(root) = pointer.asset_root.as_deref() else {
4003 return Ok(std::collections::BTreeMap::new());
4004 };
4005 let mut assets = std::collections::BTreeMap::new();
4006 let mut after = String::new();
4007 loop {
4008 let encoded_after: String =
4009 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4010 let path = format!(
4011 "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4012 pointer.commit_hash
4013 );
4014 let value = ensure_ok(
4015 request_capped(
4016 cfg,
4017 "GET",
4018 &path,
4019 None,
4020 Auth::Required,
4021 MAX_FEED_RESPONSE_BYTES,
4022 )?,
4023 "v2 asset manifest",
4024 )?;
4025 let page: V2AssetManifestPage = serde_json::from_value(value)
4026 .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4027 if page.v != 2
4028 || page.commit != pointer.commit_hash
4029 || page.asset_root.as_deref() != Some(root)
4030 || page.assets.len() > 500
4031 {
4032 return Err(invalid_feed(
4033 "v2 asset manifest is not bound to the verified head",
4034 ));
4035 }
4036 for item in page.assets {
4037 verify_v2_asset_proof(root, &item)?;
4038 let path = item.path.clone();
4039 if assets
4040 .insert(
4041 path,
4042 V2BaselineAsset {
4043 blob_sha256: item.blob_sha256,
4044 bytes: item.bytes,
4045 media_type: item.media_type,
4046 wrappers: item.wrappers,
4047 required: item.required,
4048 disposition: item.disposition,
4049 leaf_hash: item.leaf_hash,
4050 },
4051 )
4052 .is_some()
4053 {
4054 return Err(invalid_feed("v2 asset manifest repeats a path"));
4055 }
4056 if assets.len() > MAX_PUSH_FILES {
4057 return Err(invalid_feed(
4058 "v2 asset manifest exceeds the item-count bound",
4059 ));
4060 }
4061 }
4062 match page.next_cursor {
4063 None => break,
4064 Some(next) if next > after => after = next,
4065 Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4066 }
4067 }
4068 Ok(assets)
4069}
4070
4071fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4072 crate::AssetRecord {
4073 path: path.to_string(),
4074 sha256: asset.blob_sha256.clone(),
4075 bytes: asset.bytes,
4076 media_type: asset.media_type.clone(),
4077 wrappers: asset.wrappers.clone(),
4078 required: asset.required,
4079 }
4080}
4081
4082fn v2_asset_resumes_hosting(
4083 remote: Option<&V2BaselineAsset>,
4084 path: &str,
4085 record: &crate::AssetRecord,
4086 disposition: &str,
4087) -> bool {
4088 remote.is_some_and(|asset| {
4089 asset.disposition == "withheld"
4090 && disposition == "hosted"
4091 && v2_asset_record(asset, path) == *record
4092 })
4093}
4094
4095fn v2_asset_record_manifest_bytes(
4096 assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4097) -> LinkResult<Vec<u8>> {
4098 let mut bytes = Vec::new();
4099 for (path, asset) in assets {
4100 if asset.path != *path {
4101 return Err(invalid_feed(
4102 "local asset manifest key differs from its record path",
4103 ));
4104 }
4105 serde_json::to_writer(&mut bytes, asset)
4106 .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4107 bytes.push(b'\n');
4108 }
4109 Ok(bytes)
4110}
4111
4112fn v2_local_asset_records(
4113 store: &Store,
4114) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4115 let assets = crate::assets::read_manifest(store)
4116 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?;
4117 if assets.iter().any(|asset| asset.bytes > MAX_ASSET_BYTES) {
4118 return Err(LinkError::InvalidPack {
4119 message: "local asset exceeds the link.md v2 per-blob limit".to_string(),
4120 });
4121 }
4122 Ok(assets
4123 .into_iter()
4124 .map(|asset| (asset.path.clone(), asset))
4125 .collect())
4126}
4127
4128fn v2_asset_records_match_remote(
4129 local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4130 remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4131) -> bool {
4132 local.len() == remote.len()
4133 && remote
4134 .iter()
4135 .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4136}
4137
4138#[derive(Debug, Clone, PartialEq, Eq)]
4139struct V2PulledMerge<T> {
4140 records: std::collections::BTreeMap<String, T>,
4141 accept_remote: std::collections::BTreeSet<String>,
4142 conflicts: Vec<String>,
4143}
4144
4145fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4151 base: &std::collections::BTreeMap<String, Base>,
4152 remote: &std::collections::BTreeMap<String, Remote>,
4153 local: &std::collections::BTreeMap<String, Record>,
4154 base_record: BaseRecord,
4155 remote_record: RemoteRecord,
4156 keep_local: KeepLocal,
4157) -> V2PulledMerge<Record>
4158where
4159 Record: Clone + Eq,
4160 BaseRecord: Fn(&Base, &str) -> Record,
4161 RemoteRecord: Fn(&Remote, &str) -> Record,
4162 KeepLocal: Fn(&str) -> bool,
4163{
4164 let paths = base
4165 .keys()
4166 .chain(remote.keys())
4167 .chain(local.keys())
4168 .cloned()
4169 .collect::<std::collections::BTreeSet<_>>();
4170 let mut records = local.clone();
4171 let mut accept_remote = std::collections::BTreeSet::new();
4172 let mut conflicts = Vec::new();
4173 for path in paths {
4174 if keep_local(&path) {
4175 continue;
4176 }
4177 let base_value = base.get(&path).map(|value| base_record(value, &path));
4178 let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4179 let local_value = local.get(&path).cloned();
4180 if local_value != base_value && remote_value != base_value && local_value != remote_value {
4181 conflicts.push(path);
4182 continue;
4183 }
4184 if local_value == base_value || local_value == remote_value {
4185 accept_remote.insert(path.clone());
4186 match remote_value {
4187 Some(value) => {
4188 records.insert(path, value);
4189 }
4190 None => {
4191 records.remove(&path);
4192 }
4193 }
4194 }
4195 }
4196 V2PulledMerge {
4197 records,
4198 accept_remote,
4199 conflicts,
4200 }
4201}
4202
4203fn sign_verified_v2_candidate(
4204 cfg: &HubConfig,
4205 head: &V2VerifiedHead,
4206 expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4207 expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4208 mutation_id: &str,
4209 request_body: &Value,
4210 challenge_value: &Value,
4211) -> LinkResult<(String, String, String)> {
4212 if head.view_kind != "full" {
4213 return Err(invalid_feed(
4214 "a scoped self-custody writer must use the proposal workflow",
4215 ));
4216 }
4217 if head.identity.custody != "self" {
4218 return Err(invalid_feed(
4219 "a hub-custodied brain unexpectedly requested an external signature",
4220 ));
4221 }
4222 let key = cfg
4223 .brain_key
4224 .as_ref()
4225 .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4226 if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4227 || key.public_key_spki != head.identity.public_key_spki
4228 {
4229 return Err(bad_agent_key(
4230 "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4231 ));
4232 }
4233 let challenge_id = challenge_value
4234 .get("id")
4235 .and_then(Value::as_str)
4236 .filter(|id| crate::ulid::is_ulid(id))
4237 .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4238 let expected_endpoint = format!(
4239 "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4240 head.brain_id
4241 );
4242 if challenge_value
4243 .get("candidate_endpoint")
4244 .and_then(Value::as_str)
4245 != Some(expected_endpoint.as_str())
4246 {
4247 return Err(invalid_feed(
4248 "self-custody challenge candidate endpoint is not origin-bound",
4249 ));
4250 }
4251
4252 let mut files = std::collections::BTreeMap::new();
4253 let mut after = String::new();
4254 type CandidateCoordinate = (
4255 String,
4256 String,
4257 String,
4258 String,
4259 Option<String>,
4260 Option<String>,
4261 u64,
4262 Option<String>,
4263 );
4264 let mut pinned: Option<CandidateCoordinate> = None;
4265 loop {
4266 let encoded_after: String =
4267 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4268 let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4269 let value = ensure_ok(
4270 request_capped(
4271 cfg,
4272 "GET",
4273 &path,
4274 None,
4275 Auth::Required,
4276 MAX_FEED_RESPONSE_BYTES,
4277 )?,
4278 "v2 self-custody candidate",
4279 )?;
4280 let page: V2SigningCandidatePage = serde_json::from_value(value)
4281 .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4282 if page.v != 2
4283 || page.challenge_id != challenge_id
4284 || page.mutation_id != mutation_id
4285 || page.candidate.seq != page.parent.seq + 1
4286 || page.files.len() > 500
4287 || page.expires_at.is_empty()
4288 {
4289 return Err(invalid_feed(
4290 "self-custody candidate is not bound to this mutation",
4291 ));
4292 }
4293 let coordinate = (
4294 page.request_hash.clone(),
4295 page.candidate.signing_bytes_base64.clone(),
4296 page.candidate.changes_base64.clone(),
4297 page.candidate.actor_claim_base64.clone(),
4298 page.candidate.content_root.clone(),
4299 page.candidate.asset_root.clone(),
4300 page.parent.seq,
4301 page.parent.commit_hash.clone(),
4302 );
4303 if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4304 return Err(invalid_feed(
4305 "self-custody candidate changed between manifest pages",
4306 ));
4307 }
4308 pinned = Some(coordinate);
4309 let root = page
4310 .candidate
4311 .content_root
4312 .as_deref()
4313 .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4314 for file in page.files {
4315 verify_v2_file_proof(root, &file)?;
4316 if files
4317 .insert(
4318 file.path.clone(),
4319 V2BaselineFile {
4320 sha256: file.sha256,
4321 bytes: file.bytes,
4322 proof: Some(file.proof),
4323 },
4324 )
4325 .is_some()
4326 {
4327 return Err(invalid_feed(
4328 "self-custody candidate repeats a manifest path",
4329 ));
4330 }
4331 if files.len() > MAX_PUSH_FILES {
4332 return Err(invalid_feed(
4333 "self-custody candidate exceeds the file-count bound",
4334 ));
4335 }
4336 }
4337 match page.next_cursor {
4338 None => break,
4339 Some(next) if next > after => after = next,
4340 Some(_) => {
4341 return Err(invalid_feed(
4342 "self-custody candidate cursor did not advance",
4343 ))
4344 }
4345 }
4346 }
4347 if files.len() != expected.len()
4348 || files.iter().any(|(path, file)| {
4349 expected.get(path).is_none_or(|expected| {
4350 expected.sha256 != file.sha256 || expected.bytes != file.bytes
4351 })
4352 })
4353 {
4354 return Err(invalid_feed(
4355 "self-custody candidate contains an unexpected file mutation",
4356 ));
4357 }
4358 let mut assets = std::collections::BTreeMap::new();
4359 after.clear();
4360 loop {
4361 let encoded_after: String =
4362 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4363 let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4364 let value = ensure_ok(
4365 request_capped(
4366 cfg,
4367 "GET",
4368 &path,
4369 None,
4370 Auth::Required,
4371 MAX_FEED_RESPONSE_BYTES,
4372 )?,
4373 "v2 self-custody asset candidate",
4374 )?;
4375 let page: V2SigningCandidatePage = serde_json::from_value(value)
4376 .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4377 let coordinate = (
4378 page.request_hash.clone(),
4379 page.candidate.signing_bytes_base64.clone(),
4380 page.candidate.changes_base64.clone(),
4381 page.candidate.actor_claim_base64.clone(),
4382 page.candidate.content_root.clone(),
4383 page.candidate.asset_root.clone(),
4384 page.parent.seq,
4385 page.parent.commit_hash.clone(),
4386 );
4387 if page.v != 2
4388 || page.challenge_id != challenge_id
4389 || page.mutation_id != mutation_id
4390 || page.assets.len() > 500
4391 || pinned.as_ref() != Some(&coordinate)
4392 {
4393 return Err(invalid_feed(
4394 "self-custody asset candidate changed or is not bound",
4395 ));
4396 }
4397 let root = page.candidate.asset_root.as_deref();
4398 if !page.assets.is_empty() && root.is_none() {
4399 return Err(invalid_feed("asset candidate has no asset root"));
4400 }
4401 for item in page.assets {
4402 verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4403 if assets
4404 .insert(
4405 item.path.clone(),
4406 V2BaselineAsset {
4407 blob_sha256: item.blob_sha256,
4408 bytes: item.bytes,
4409 media_type: item.media_type,
4410 wrappers: item.wrappers,
4411 required: item.required,
4412 disposition: item.disposition,
4413 leaf_hash: item.leaf_hash,
4414 },
4415 )
4416 .is_some()
4417 {
4418 return Err(invalid_feed("self-custody candidate repeats an asset"));
4419 }
4420 }
4421 match page.next_cursor {
4422 None => break,
4423 Some(next) if next > after => after = next,
4424 Some(_) => {
4425 return Err(invalid_feed(
4426 "self-custody asset candidate cursor did not advance",
4427 ))
4428 }
4429 }
4430 }
4431 if assets.len() != expected_assets.len()
4432 || assets.iter().any(|(path, asset)| {
4433 expected_assets.get(path).is_none_or(|expected| {
4434 asset.blob_sha256 != expected.blob_sha256
4435 || asset.bytes != expected.bytes
4436 || asset.media_type != expected.media_type
4437 || asset.wrappers != expected.wrappers
4438 || asset.required != expected.required
4439 || asset.disposition != expected.disposition
4440 })
4441 })
4442 {
4443 return Err(invalid_feed(
4444 "self-custody candidate contains an unexpected asset mutation",
4445 ));
4446 }
4447 let Some((
4448 request_hash,
4449 signing_b64,
4450 changes_b64,
4451 actor_b64,
4452 root,
4453 asset_root,
4454 parent_seq,
4455 parent,
4456 )) = pinned
4457 else {
4458 return Err(invalid_feed("self-custody candidate has no manifest"));
4459 };
4460 let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4461 let current_commit = head
4462 .pointer
4463 .as_ref()
4464 .map(|pointer| pointer.commit_hash.clone());
4465 if parent_seq != current_seq || parent != current_commit {
4466 return Err(LinkError::RemoteAdvancedDuringSync);
4467 }
4468 let changes = STANDARD
4469 .decode(changes_b64)
4470 .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4471 let mut expected_changes = json!({
4472 "mutation_id": mutation_id,
4473 "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4474 "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4475 "v": 2,
4476 });
4477 if let Some(withheld_links) = request_body.get("withheld_links") {
4478 expected_changes["withheld_links"] = withheld_links.clone();
4479 }
4480 if let Some(checkout_id) = request_body.get("checkout_id") {
4481 expected_changes["checkout_id"] = checkout_id.clone();
4482 }
4483 let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4484 .map_err(|error| invalid_feed(error.to_string()))?;
4485 if changes != expected_changes_bytes {
4486 return Err(invalid_feed(
4487 "self-custody changeset differs from the requested mutation",
4488 ));
4489 }
4490 let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4491 .map_err(|error| invalid_feed(error.to_string()))?;
4492 let request_value = json!({
4493 "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4494 "brain": head.brain_id,
4495 "changes_sha256": changes_hash,
4496 "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4497 "v": 2,
4498 "v1_bridge": Value::Null,
4499 });
4500 let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4501 .map_err(|error| invalid_feed(error.to_string()))?;
4502 if request_hash != expected_request_hash {
4503 return Err(invalid_feed(
4504 "self-custody request hash differs from the requested mutation",
4505 ));
4506 }
4507 let actor = STANDARD
4508 .decode(actor_b64)
4509 .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4510 let actor_value: Value = serde_json::from_slice(&actor)
4511 .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4512 if crate::linkmd_v2::canonical_bytes(&actor_value)
4513 .map_err(|error| invalid_feed(error.to_string()))?
4514 != actor
4515 {
4516 return Err(invalid_feed("self-custody actor claim is not canonical"));
4517 }
4518 let actor_object = actor_value
4519 .as_object()
4520 .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4521 let actor_claim = actor_object
4522 .get("claim")
4523 .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4524 let actor_public_key = actor_object
4525 .get("public_key")
4526 .and_then(Value::as_str)
4527 .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4528 let actor_fingerprint = actor_object
4529 .get("fingerprint")
4530 .and_then(Value::as_str)
4531 .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4532 let actor_signature = actor_object
4533 .get("sig")
4534 .and_then(Value::as_str)
4535 .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4536 let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4537 .map_err(|error| invalid_feed(error.to_string()))?;
4538 let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4539 let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4540 let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4541 let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4542 let impact = actor_claim
4543 .get("result")
4544 .and_then(|result| result.get("impact"))
4545 .and_then(Value::as_object);
4546 let impact_fields = [
4547 "creates",
4548 "updates",
4549 "deletes",
4550 "withdrawals",
4551 "renames",
4552 "restores",
4553 "asset_changes",
4554 "public_expansions",
4555 "executable_activations",
4556 ];
4557 let impact_is_valid = impact.is_some_and(|impact| {
4558 impact.len() == impact_fields.len() + 1
4559 && impact.get("v").and_then(Value::as_u64) == Some(1)
4560 && impact_fields
4561 .iter()
4562 .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4563 });
4564 if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4565 || head
4566 .trust
4567 .hub_signer
4568 .as_ref()
4569 .is_some_and(|known| known != &expected_actor_signer)
4570 || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4571 || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4572 || actor_claim
4573 .get("candidate")
4574 .and_then(|candidate| candidate.get("changes_sha256"))
4575 .and_then(Value::as_str)
4576 != Some(changes_hash.as_str())
4577 || actor_claim
4578 .get("candidate")
4579 .and_then(|candidate| candidate.get("state_root"))
4580 != Some(&expected_actor_root)
4581 || actor_claim
4582 .get("candidate")
4583 .and_then(|candidate| candidate.get("asset_root"))
4584 != Some(&expected_actor_asset_root)
4585 || actor_claim
4586 .get("candidate")
4587 .and_then(|candidate| candidate.get("control_revision"))
4588 .and_then(Value::as_str)
4589 != Some(head.control_revision.as_str())
4590 || !impact_is_valid
4591 {
4592 return Err(invalid_feed(
4593 "self-custody actor claim does not bind the verified authority",
4594 ));
4595 }
4596 let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4597 .map_err(|error| invalid_feed(error.to_string()))?;
4598 let signing = STANDARD
4599 .decode(signing_b64)
4600 .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4601 let signing_value: Value = serde_json::from_slice(&signing)
4602 .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4603 if crate::linkmd_v2::canonical_bytes(&signing_value)
4604 .map_err(|error| invalid_feed(error.to_string()))?
4605 != signing
4606 {
4607 return Err(invalid_feed("self-custody signing bytes are not canonical"));
4608 }
4609 let pointer = head.pointer.as_ref();
4610 let expected_materializer = pointer
4611 .map(|value| value.materializer.as_str())
4612 .unwrap_or("dbmd-projection-v1");
4613 let expected_parent_commit = request_body
4614 .get("base")
4615 .and_then(|base| base.get("commit_hash"))
4616 .cloned()
4617 .unwrap_or(Value::Null);
4618 let expected_parent_root = request_body
4619 .get("base")
4620 .and_then(|base| base.get("content_root"))
4621 .cloned()
4622 .unwrap_or(Value::Null);
4623 let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4624 let expected_parent_asset_root = request_body
4625 .get("base")
4626 .and_then(|base| base.get("asset_root"))
4627 .cloned()
4628 .unwrap_or(Value::Null);
4629 let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4630 let expected_prev_entry = pointer
4631 .map(|value| Value::String(value.feed_hash.clone()))
4632 .unwrap_or(Value::Null);
4633 let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4634 .map_err(|_| invalid_feed("brain identity history is too large"))?
4635 + 1;
4636 if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4637 || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4638 || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4639 || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4640 || signing_value.get("public_key").and_then(Value::as_str)
4641 != Some(key.public_key_spki.as_str())
4642 || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4643 || signing_value.get("parent_root") != Some(&expected_parent_root)
4644 || signing_value.get("state_root") != Some(&expected_state_root)
4645 || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4646 || signing_value.get("asset_root") != Some(&expected_asset_root)
4647 || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4648 || signing_value.get("changes_sha256").and_then(Value::as_str)
4649 != Some(changes_hash.as_str())
4650 || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4651 || signing_value
4652 .get("control_revision")
4653 .and_then(Value::as_str)
4654 != Some(head.control_revision.as_str())
4655 || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4656 || signing_value.get("v1_bridge") != Some(&Value::Null)
4657 || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4658 {
4659 return Err(invalid_feed(
4660 "self-custody signing bytes do not bind the verified candidate",
4661 ));
4662 }
4663 let pair = agent_keypair(&key.pkcs8)?;
4664 let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4665 Ok((challenge_id.to_string(), signature, expected_actor_signer))
4666}
4667
4668fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4669 let origin = normalized_origin(&cfg.hub)?;
4670 let absolute = if checkout.is_absolute() {
4671 checkout.to_path_buf()
4672 } else {
4673 std::env::current_dir()?.join(checkout)
4674 };
4675 Ok(format!(
4676 "sync-{}.json",
4677 content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4678 ))
4679}
4680
4681fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4682 if let Some(value) = existing {
4683 if !is_sha256(value) {
4684 return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4685 }
4686 return Ok(value.to_string());
4687 }
4688 use ring::rand::SecureRandom as _;
4689 let mut random = [0_u8; 32];
4690 ring::rand::SystemRandom::new()
4691 .fill(&mut random)
4692 .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4693 Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4694}
4695
4696#[cfg(any(unix, windows))]
4697fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4698 let directory = open_trust_dir(cfg)?;
4699 let origin = normalized_origin(&cfg.hub)?;
4700 let name = format!(
4701 "operation-{}.lock",
4702 content_sha256(format!("{origin}\0{brain}").as_bytes())
4703 );
4704 lock_trust_name(&directory, &name)
4705}
4706
4707#[cfg(not(any(unix, windows)))]
4708fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4709 Err(LinkError::UnsupportedPlatform {
4710 operation: "serialized link.md v2 sync",
4711 })
4712}
4713
4714fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4715 left.brain_id == right.brain_id
4716 && left.view_kind == right.view_kind
4717 && left.view_revision == right.view_revision
4718 && left.control_revision == right.control_revision
4719 && match (&left.pointer, &right.pointer) {
4720 (None, None) => true,
4721 (Some(left), Some(right)) => {
4722 left.seq == right.seq
4723 && left.commit_hash == right.commit_hash
4724 && left.content_root == right.content_root
4725 && left.asset_root == right.asset_root
4726 && left.feed_hash == right.feed_hash
4727 }
4728 _ => false,
4729 }
4730}
4731
4732fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4733 format!(
4734 "---\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"
4735 )
4736 .into_bytes()
4737}
4738
4739fn scoped_projection_sha256(brain: &str) -> String {
4740 content_sha256(&scoped_projection_bytes(brain))
4741}
4742
4743#[derive(Deserialize)]
4744struct LocalScopedViewMarker {
4745 v: u8,
4746 kind: String,
4747 authoritative: bool,
4748 brain: String,
4749 projection_sha256: String,
4750}
4751
4752pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4756 let marker = store
4757 .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4758 .ok()
4759 .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4760 let Some(marker) = marker else {
4761 return false;
4762 };
4763 if marker.v != 1
4764 || marker.kind != "link.md-scoped-view"
4765 || marker.authoritative
4766 || !crate::ulid::is_ulid(&marker.brain)
4767 || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4768 {
4769 return false;
4770 }
4771 store
4772 .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4773 .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4774}
4775
4776fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4777 let mut bytes = serde_json::to_vec_pretty(&json!({
4778 "v": 1,
4779 "kind": "link.md-scoped-view",
4780 "authoritative": false,
4781 "brain": head.brain_id,
4782 "view_revision": head.view_revision,
4783 "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4784 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4785 "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4786 "visible_files": files,
4787 "projection_sha256": scoped_projection_sha256(&head.brain_id),
4788 }))
4789 .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4790 bytes.push(b'\n');
4791 Ok(bytes)
4792}
4793
4794fn refresh_scoped_view_marker(
4795 store: &Store,
4796 head: &V2VerifiedHead,
4797 files: usize,
4798) -> LinkResult<()> {
4799 if head.view_kind == "scoped" {
4800 store.write_atomic(
4801 Path::new(".dbmd/view.json"),
4802 &scoped_view_metadata(head, files)?,
4803 )?;
4804 }
4805 Ok(())
4806}
4807
4808fn ensure_v2_view_compatible(
4809 head: &V2VerifiedHead,
4810 baseline: Option<&V2SyncBaseline>,
4811) -> LinkResult<()> {
4812 let Some(baseline) = baseline else {
4813 return Ok(());
4814 };
4815 match (
4816 baseline.view_kind.as_deref(),
4817 baseline.view_revision.as_deref(),
4818 ) {
4819 (None, None) if head.view_kind == "full" => Ok(()),
4820 (Some(kind), Some(revision))
4821 if kind == head.view_kind && revision == head.view_revision =>
4822 {
4823 Ok(())
4824 }
4825 _ => Err(LinkError::ScopedViewChanged),
4826 }
4827}
4828
4829fn ensure_established_v2_checkout_opened(
4830 head: &V2VerifiedHead,
4831 baseline: Option<&V2SyncBaseline>,
4832 opened: bool,
4833) -> LinkResult<()> {
4834 if baseline.is_none() || opened {
4835 return Ok(());
4836 }
4837 if head.view_kind == "scoped" {
4838 return Err(LinkError::ScopedProjectionModified);
4839 }
4840 Err(LinkError::InvalidPack {
4841 message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4842 })
4843}
4844
4845fn remove_scoped_projection(
4846 head: &V2VerifiedHead,
4847 baseline: Option<&V2SyncBaseline>,
4848 view: &mut V2LocalView,
4849) -> LinkResult<()> {
4850 if head.view_kind != "scoped" {
4851 return Ok(());
4852 }
4853 let expected = scoped_projection_sha256(&head.brain_id);
4854 if baseline
4855 .and_then(|state| state.projection_sha256.as_deref())
4856 .is_some_and(|pinned| pinned != expected)
4857 {
4858 return Err(LinkError::ScopedViewChanged);
4859 }
4860 if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4861 return Err(LinkError::ScopedProjectionModified);
4862 }
4863 view.riding.remove("DB.md");
4864 view.eligibility.remove("DB.md");
4865 Ok(())
4866}
4867
4868fn local_view_for_v2_push(
4869 store: &Store,
4870 head: &V2VerifiedHead,
4871 baseline: Option<&V2SyncBaseline>,
4872 carried: Option<V2LocalView>,
4873) -> LinkResult<V2LocalView> {
4874 match carried {
4875 Some(view) => Ok(view),
4880 None => {
4881 let mut view = v2_local_files(store)?;
4882 remove_scoped_projection(head, baseline, &mut view)?;
4883 Ok(view)
4884 }
4885 }
4886}
4887
4888fn files_for_v2_view(
4889 head: &V2VerifiedHead,
4890 mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4891) -> std::collections::BTreeMap<String, V2BaselineFile> {
4892 if head.view_kind == "scoped" {
4893 files.remove("DB.md");
4897 }
4898 files
4899}
4900
4901fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4902 let baseline: V2SyncBaseline =
4903 serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
4904 if baseline.v != 2
4905 || baseline.origin != normalized_origin(&cfg.hub)?
4906 || baseline.brain != brain
4907 || baseline
4908 .commit_hash
4909 .as_deref()
4910 .is_some_and(|hash| !is_sha256(hash))
4911 || baseline
4912 .content_root
4913 .as_deref()
4914 .is_some_and(|hash| !is_sha256(hash))
4915 || baseline
4916 .asset_root
4917 .as_deref()
4918 .is_some_and(|hash| !is_sha256(hash))
4919 || baseline
4920 .local_policy_digest
4921 .as_deref()
4922 .is_some_and(|hash| !is_sha256(hash))
4923 || baseline
4924 .view_kind
4925 .as_deref()
4926 .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
4927 || baseline
4928 .view_revision
4929 .as_deref()
4930 .is_some_and(|hash| !is_sha256(hash))
4931 || baseline
4932 .projection_sha256
4933 .as_deref()
4934 .is_some_and(|hash| !is_sha256(hash))
4935 || (baseline.view_kind.as_deref() == Some("scoped")
4936 && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
4937 || baseline.files.len() > MAX_PUSH_FILES
4938 || baseline.assets.len() > MAX_PUSH_FILES
4939 || baseline.local_eligibility.len() > MAX_PUSH_FILES
4940 || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
4941 || baseline.files.iter().any(|(path, file)| {
4942 crate::linkmd_v2::normalize_path(path).is_err()
4943 || !is_sha256(&file.sha256)
4944 || file.bytes > MAX_STORE_BYTES
4945 })
4946 || baseline.assets.iter().any(|(path, asset)| {
4947 crate::linkmd_v2::normalize_path(path).is_err()
4948 || !is_sha256(&asset.blob_sha256)
4949 || !is_sha256(&asset.leaf_hash)
4950 || asset.bytes > MAX_ASSET_BYTES
4951 || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
4952 || asset.wrappers.is_empty()
4953 || asset
4954 .wrappers
4955 .iter()
4956 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4957 })
4958 || baseline
4959 .local_eligibility
4960 .keys()
4961 .chain(baseline.remote_copy_remains.keys())
4962 .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
4963 || baseline
4964 .remote_copy_remains
4965 .values()
4966 .any(|hash| !is_sha256(hash))
4967 || baseline
4968 .checkout_id
4969 .as_deref()
4970 .is_some_and(|checkout_id| !is_sha256(checkout_id))
4971 {
4972 return Err(invalid_feed("v2 sync baseline failed validation"));
4973 }
4974 Ok(baseline)
4975}
4976
4977#[cfg(unix)]
4978fn load_v2_baseline(
4979 cfg: &HubConfig,
4980 brain: &str,
4981 checkout: &Path,
4982) -> LinkResult<Option<V2SyncBaseline>> {
4983 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4984 let directory = open_trust_dir(cfg)?;
4985 let name_string = v2_baseline_name(cfg, brain, checkout)?;
4986 let _lock = lock_trust_name(&directory, &name_string)?;
4987 let name = c_name(name_string.as_bytes(), &name_string)?;
4988 let fd = unsafe {
4989 libc::openat(
4990 directory.as_raw_fd(),
4991 name.as_ptr(),
4992 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4993 )
4994 };
4995 if fd < 0 {
4996 let error = std::io::Error::last_os_error();
4997 return if error.kind() == std::io::ErrorKind::NotFound {
4998 Ok(None)
4999 } else {
5000 Err(LinkError::UnsafePath { path: name_string })
5001 };
5002 }
5003 let file = unsafe { std::fs::File::from_raw_fd(fd) };
5004 let mut bytes = Vec::new();
5005 file.take(MAX_FEED_RESPONSE_BYTES + 1)
5006 .read_to_end(&mut bytes)?;
5007 if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
5008 return Err(invalid_feed("v2 sync baseline is oversized"));
5009 }
5010 Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
5011}
5012
5013#[cfg(windows)]
5014fn load_v2_baseline(
5015 cfg: &HubConfig,
5016 brain: &str,
5017 checkout: &Path,
5018) -> LinkResult<Option<V2SyncBaseline>> {
5019 let directory = open_trust_dir(cfg)?;
5020 let name = v2_baseline_name(cfg, brain, checkout)?;
5021 let _lock = lock_trust_name(&directory, &name)?;
5022 let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
5023 match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
5024 Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5025 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5026 Err(_) => Err(LinkError::UnsafePath { path: name }),
5027 }
5028}
5029
5030#[cfg(not(any(unix, windows)))]
5031fn load_v2_baseline(
5032 _cfg: &HubConfig,
5033 _brain: &str,
5034 _checkout: &Path,
5035) -> LinkResult<Option<V2SyncBaseline>> {
5036 Err(LinkError::UnsupportedPlatform {
5037 operation: "verified link.md v2 baseline",
5038 })
5039}
5040
5041#[cfg(unix)]
5042fn save_v2_baseline(
5043 cfg: &HubConfig,
5044 brain: &str,
5045 checkout: &Path,
5046 baseline: &V2SyncBaseline,
5047) -> LinkResult<()> {
5048 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5049 let directory = open_trust_dir(cfg)?;
5050 let name_string = v2_baseline_name(cfg, brain, checkout)?;
5051 let _lock = lock_trust_name(&directory, &name_string)?;
5052 let name = c_name(name_string.as_bytes(), &name_string)?;
5053 let mut bytes = serde_json::to_vec(baseline)
5054 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5055 bytes.push(b'\n');
5056 let temp_string = format!(
5057 ".{name_string}.tmp.{}-{}",
5058 std::process::id(),
5059 std::time::SystemTime::now()
5060 .duration_since(std::time::UNIX_EPOCH)
5061 .unwrap_or_default()
5062 .as_nanos()
5063 );
5064 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5065 let fd = unsafe {
5066 libc::openat(
5067 directory.as_raw_fd(),
5068 temp.as_ptr(),
5069 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5070 0o600,
5071 )
5072 };
5073 if fd < 0 {
5074 return Err(std::io::Error::last_os_error().into());
5075 }
5076 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5077 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5078 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5079 return Err(error.into());
5080 }
5081 drop(file);
5082 if unsafe {
5083 libc::renameat(
5084 directory.as_raw_fd(),
5085 temp.as_ptr(),
5086 directory.as_raw_fd(),
5087 name.as_ptr(),
5088 )
5089 } != 0
5090 {
5091 let error = std::io::Error::last_os_error();
5092 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5093 return Err(error.into());
5094 }
5095 directory.sync_all()?;
5096 Ok(())
5097}
5098
5099#[cfg(windows)]
5100fn save_v2_baseline(
5101 cfg: &HubConfig,
5102 brain: &str,
5103 checkout: &Path,
5104 baseline: &V2SyncBaseline,
5105) -> LinkResult<()> {
5106 let directory = open_trust_dir(cfg)?;
5107 let name = v2_baseline_name(cfg, brain, checkout)?;
5108 let _lock = lock_trust_name(&directory, &name)?;
5109 let mut bytes = serde_json::to_vec(baseline)
5110 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5111 bytes.push(b'\n');
5112 crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5113 Ok(())
5114}
5115
5116#[cfg(not(any(unix, windows)))]
5117fn save_v2_baseline(
5118 _cfg: &HubConfig,
5119 _brain: &str,
5120 _checkout: &Path,
5121 _baseline: &V2SyncBaseline,
5122) -> LinkResult<()> {
5123 Err(LinkError::UnsupportedPlatform {
5124 operation: "verified link.md v2 baseline",
5125 })
5126}
5127
5128fn v2_baseline_from_head(
5129 cfg: &HubConfig,
5130 head: &V2VerifiedHead,
5131 files: std::collections::BTreeMap<String, V2BaselineFile>,
5132 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5133 local: Option<&V2LocalView>,
5134 checkout_id: Option<&str>,
5135) -> LinkResult<V2SyncBaseline> {
5136 let mut local_eligibility = local
5137 .map(|view| view.eligibility.clone())
5138 .unwrap_or_default();
5139 if let Some(view) = local {
5140 for path in files.keys() {
5141 local_eligibility
5142 .entry(path.clone())
5143 .or_insert_with(|| !view.policy.keeps_home(path));
5144 }
5145 }
5146 let remote_copy_remains = local_eligibility
5147 .iter()
5148 .filter(|(_, riding)| !**riding)
5149 .filter_map(|(path, _)| {
5150 files
5151 .get(path)
5152 .map(|file| (path.clone(), file.sha256.clone()))
5153 })
5154 .collect();
5155 Ok(V2SyncBaseline {
5156 v: 2,
5157 origin: normalized_origin(&cfg.hub)?,
5158 brain: head.brain_id.clone(),
5159 checkout_id: Some(v2_checkout_id(checkout_id)?),
5160 head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5161 commit_hash: head
5162 .pointer
5163 .as_ref()
5164 .map(|pointer| pointer.commit_hash.clone()),
5165 content_root: head
5166 .pointer
5167 .as_ref()
5168 .and_then(|pointer| pointer.content_root.clone()),
5169 asset_root: head
5170 .pointer
5171 .as_ref()
5172 .and_then(|pointer| pointer.asset_root.clone()),
5173 assets,
5174 view_kind: Some(head.view_kind.clone()),
5175 view_revision: Some(head.view_revision.clone()),
5176 projection_sha256: (head.view_kind == "scoped")
5177 .then(|| scoped_projection_sha256(&head.brain_id)),
5178 files,
5179 local_policy_digest: local.map(|view| view.policy.digest.clone()),
5180 local_eligibility,
5181 remote_copy_remains,
5182 })
5183}
5184
5185fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5186 let policy = crate::linkmd_sync_policy::load(store)
5187 .map_err(|message| LinkError::InvalidPack { message })?;
5188 let asset_paths = crate::assets::read_manifest(store)
5189 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5190 .into_iter()
5191 .map(|asset| asset.path)
5192 .collect::<std::collections::BTreeSet<_>>();
5193 let mut result = std::collections::BTreeMap::new();
5194 let mut eligibility = std::collections::BTreeMap::new();
5195 let mut riding_links = Vec::<(String, Vec<String>)>::new();
5196 let mut total = 0_u64;
5197 let mut paths = vec![PathBuf::from("DB.md")];
5198 paths.extend(store.walk()?);
5199 for relative in paths {
5200 let path = relative.to_string_lossy().replace('\\', "/");
5201 if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5203 continue;
5204 }
5205 if asset_paths.contains(&path) {
5206 continue;
5207 }
5208 crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5209 path: error.to_string(),
5210 })?;
5211 let riding = !policy.keeps_home(&path);
5212 eligibility.insert(path.clone(), riding);
5213 if !riding {
5214 continue;
5215 }
5216 let remaining = MAX_STORE_BYTES.saturating_sub(total);
5217 let bytes = store.read_bounded(&relative, remaining)?;
5218 total = total
5219 .checked_add(bytes.len() as u64)
5220 .ok_or_else(|| LinkError::PushTooLarge {
5221 detail: "v2 local byte count overflow".to_string(),
5222 })?;
5223 if total > MAX_STORE_BYTES {
5224 return Err(LinkError::PushTooLarge {
5225 detail: format!("{total} uncompressed bytes"),
5226 });
5227 }
5228 if std::str::from_utf8(&bytes).is_err() {
5229 return Err(LinkError::NotUtf8 { path });
5230 }
5231 let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5232 riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
5233 result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
5234 }
5235 let kept_home = eligibility
5236 .iter()
5237 .filter(|(_, riding)| !**riding)
5238 .map(|(path, _)| path.clone())
5239 .collect::<std::collections::BTreeSet<_>>();
5240 let mut withheld_links = riding_links
5241 .into_iter()
5242 .flat_map(|(source, targets)| {
5243 let kept_home = &kept_home;
5244 let policy = &policy;
5245 targets.into_iter().filter_map(move |target| {
5246 let target = format!("{target}.md");
5247 (kept_home.contains(&target) || policy.keeps_home(&target)).then_some(
5257 V2WithheldLink {
5258 source: source.clone(),
5259 target,
5260 },
5261 )
5262 })
5263 })
5264 .collect::<Vec<_>>();
5265 withheld_links.sort();
5266 withheld_links.dedup();
5267 Ok(V2LocalView {
5268 riding: result,
5269 eligibility,
5270 policy,
5271 withheld_links,
5272 })
5273}
5274
5275#[derive(Debug, Deserialize)]
5276struct V2DownloadItem {
5277 path: String,
5278 sha256: String,
5279 bytes: u64,
5280 url: String,
5281 method: String,
5282}
5283
5284#[derive(Debug, Deserialize)]
5285struct V2DownloadWindow {
5286 v: u8,
5287 commit: String,
5288 downloads: Vec<V2DownloadItem>,
5289}
5290
5291#[derive(Debug, Deserialize)]
5292struct V2BulkStreamHeader {
5293 v: u8,
5294 path: String,
5295 sha256: String,
5296 bytes: u64,
5297}
5298
5299fn parse_v2_bulk_stream(
5300 bytes: &[u8],
5301 expected: &[(&String, &V2BaselineFile)],
5302) -> LinkResult<Vec<(String, Vec<u8>)>> {
5303 if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5304 return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5305 }
5306 let mut cursor = V2_BULK_STREAM_MAGIC.len();
5307 let mut result = Vec::with_capacity(expected.len());
5308 for (expected_path, expected_file) in expected {
5309 let length_bytes = bytes
5310 .get(cursor..cursor + 4)
5311 .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5312 cursor += 4;
5313 let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5314 if header_len == 0 || header_len > 4 * 1024 {
5315 return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5316 }
5317 let header_bytes = bytes
5318 .get(cursor..cursor + header_len)
5319 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5320 cursor += header_len;
5321 let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5322 .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5323 if header.v != 2
5324 || &header.path != *expected_path
5325 || header.sha256 != expected_file.sha256
5326 || header.bytes != expected_file.bytes
5327 || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5328 {
5329 return Err(invalid_feed(
5330 "v2 bulk stream frame differs from its proven manifest entry",
5331 ));
5332 }
5333 let body_len = usize::try_from(header.bytes)
5334 .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5335 let body = bytes
5336 .get(cursor..cursor + body_len)
5337 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5338 cursor += body_len;
5339 if content_sha256(body) != header.sha256 {
5340 return Err(invalid_feed(
5341 "v2 bulk stream file differs from its proven manifest entry",
5342 ));
5343 }
5344 result.push((header.path, body.to_vec()));
5345 }
5346 if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5347 return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5348 }
5349 cursor += 4;
5350 if cursor != bytes.len() {
5351 return Err(invalid_feed("v2 bulk stream carries trailing data"));
5352 }
5353 Ok(result)
5354}
5355
5356fn download_v2_bulk_stream(
5357 cfg: &HubConfig,
5358 brain: &str,
5359 pointer: &V2PointerBody,
5360 pending: &[(&String, &V2BaselineFile)],
5361) -> LinkResult<Vec<(String, Vec<u8>)>> {
5362 let claims = pending
5363 .iter()
5364 .map(|(path, file)| {
5365 Ok(json!({
5366 "path": path,
5367 "sha256": file.sha256,
5368 "bytes": file.bytes,
5369 "proof": file.proof.as_ref().ok_or_else(|| {
5370 invalid_feed("v2 manifest omitted a bulk-stream proof")
5371 })?,
5372 }))
5373 })
5374 .collect::<LinkResult<Vec<_>>>()?;
5375 let raw = request_raw(
5376 cfg,
5377 "POST",
5378 &format!("/api/hub/brains/{brain}/v2/stream"),
5379 Some(&json!({
5380 "commit": pointer.commit_hash,
5381 "files": claims,
5382 })),
5383 Auth::Required,
5384 V2_BULK_STREAM_RESPONSE_BYTES,
5385 )?;
5386 let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5387 parse_v2_bulk_stream(&body, pending)
5388}
5389
5390fn prepare_v2_downloads(
5391 cfg: &HubConfig,
5392 brain: &str,
5393 pointer: &V2PointerBody,
5394 pending: &[(&String, &V2BaselineFile)],
5395) -> LinkResult<Vec<V2DownloadItem>> {
5396 let mut result = Vec::with_capacity(pending.len());
5397 for chunk in pending.chunks(128) {
5398 let claims = chunk
5399 .iter()
5400 .map(|(path, file)| {
5401 Ok(json!({
5402 "path": path,
5403 "sha256": file.sha256,
5404 "bytes": file.bytes,
5405 "proof": file.proof.as_ref().ok_or_else(|| {
5406 invalid_feed("v2 manifest omitted a download proof")
5407 })?,
5408 }))
5409 })
5410 .collect::<LinkResult<Vec<_>>>()?;
5411 let value = ensure_ok(
5412 request_capped(
5413 cfg,
5414 "POST",
5415 &format!("/api/hub/brains/{brain}/v2/downloads"),
5416 Some(&json!({
5417 "commit": pointer.commit_hash,
5418 "files": claims,
5419 })),
5420 Auth::Required,
5421 MAX_FEED_RESPONSE_BYTES,
5422 )?,
5423 "prepare v2 blob downloads",
5424 )?;
5425 let window: V2DownloadWindow = serde_json::from_value(value)
5426 .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5427 if window.v != 2
5428 || window.commit != pointer.commit_hash
5429 || window.downloads.len() != chunk.len()
5430 {
5431 return Err(invalid_feed(
5432 "v2 download window is not bound to the requested files",
5433 ));
5434 }
5435 let mut by_path = window
5436 .downloads
5437 .into_iter()
5438 .map(|item| (item.path.clone(), item))
5439 .collect::<std::collections::BTreeMap<_, _>>();
5440 if by_path.len() != chunk.len() {
5441 return Err(invalid_feed("v2 download window repeats a path"));
5442 }
5443 for (path, file) in chunk {
5444 let item = by_path
5445 .remove(*path)
5446 .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5447 if item.method != "GET"
5448 || item.sha256 != file.sha256
5449 || item.bytes != file.bytes
5450 || item.url.is_empty()
5451 {
5452 return Err(invalid_feed(
5453 "v2 download capability differs from its proven file",
5454 ));
5455 }
5456 result.push(item);
5457 }
5458 }
5459 Ok(result)
5460}
5461
5462fn prepare_v2_asset_downloads(
5463 cfg: &HubConfig,
5464 brain: &str,
5465 pointer: &V2PointerBody,
5466 pending: &[(&String, &V2BaselineAsset)],
5467) -> LinkResult<Vec<V2DownloadItem>> {
5468 let mut result = Vec::with_capacity(pending.len());
5469 for chunk in pending.chunks(128) {
5470 let claims = chunk
5471 .iter()
5472 .map(|(path, asset)| {
5473 json!({
5474 "path": path,
5475 "sha256": asset.blob_sha256,
5476 "bytes": asset.bytes,
5477 "leaf_hash": asset.leaf_hash,
5478 })
5479 })
5480 .collect::<Vec<_>>();
5481 let value = ensure_ok(
5482 request_capped(
5483 cfg,
5484 "POST",
5485 &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5486 Some(&json!({
5487 "commit": pointer.commit_hash,
5488 "assets": claims,
5489 })),
5490 Auth::Required,
5491 MAX_FEED_RESPONSE_BYTES,
5492 )?,
5493 "prepare v2 asset downloads",
5494 )?;
5495 let window: V2DownloadWindow = serde_json::from_value(value)
5496 .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5497 if window.v != 2
5498 || window.commit != pointer.commit_hash
5499 || window.downloads.len() != chunk.len()
5500 {
5501 return Err(invalid_feed(
5502 "v2 asset download window is not bound to the requested assets",
5503 ));
5504 }
5505 let mut by_path = window
5506 .downloads
5507 .into_iter()
5508 .map(|item| (item.path.clone(), item))
5509 .collect::<std::collections::BTreeMap<_, _>>();
5510 if by_path.len() != chunk.len() {
5511 return Err(invalid_feed("v2 asset download window repeats a path"));
5512 }
5513 for (path, asset) in chunk {
5514 let item = by_path
5515 .remove(*path)
5516 .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5517 if item.method != "GET"
5518 || item.sha256 != asset.blob_sha256
5519 || item.bytes != asset.bytes
5520 || item.url.is_empty()
5521 {
5522 return Err(invalid_feed(
5523 "v2 asset download capability differs from its signed leaf",
5524 ));
5525 }
5526 result.push(item);
5527 }
5528 }
5529 Ok(result)
5530}
5531
5532fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5533 let bytes = get_presigned(cfg, &item.url)?;
5534 if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5535 return Err(invalid_feed("v2 blob differs from its proven path entry"));
5536 }
5537 Ok(bytes)
5538}
5539
5540#[derive(Debug, Clone)]
5541struct V2StagedFile {
5542 path: String,
5543 source: PathBuf,
5544 sha256: String,
5545 bytes: u64,
5546}
5547
5548#[cfg(unix)]
5549fn v2_download_cache_dir(
5550 cfg: &HubConfig,
5551 brain: &str,
5552 pointer: &V2PointerBody,
5553) -> LinkResult<PathBuf> {
5554 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5555}
5556
5557#[cfg(unix)]
5558fn v2_download_cache_dir_for(
5559 cfg: &HubConfig,
5560 brain: &str,
5561 transaction: &str,
5562) -> LinkResult<PathBuf> {
5563 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5564 return Err(invalid_feed("v2 download cache address is invalid"));
5565 }
5566 let path = cfg
5567 .state_dir
5568 .join("downloads")
5569 .join(brain)
5570 .join(transaction);
5571 let directory = open_or_create_dir_nofollow(&path)?;
5572 use std::os::fd::AsRawFd as _;
5573 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5574 return Err(std::io::Error::last_os_error().into());
5575 }
5576 directory.sync_all()?;
5577 Ok(path)
5578}
5579
5580#[cfg(windows)]
5581fn v2_download_cache_dir(
5582 cfg: &HubConfig,
5583 brain: &str,
5584 pointer: &V2PointerBody,
5585) -> LinkResult<PathBuf> {
5586 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5587}
5588
5589#[cfg(windows)]
5590fn v2_download_cache_dir_for(
5591 cfg: &HubConfig,
5592 brain: &str,
5593 transaction: &str,
5594) -> LinkResult<PathBuf> {
5595 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5596 return Err(invalid_feed("v2 download cache address is invalid"));
5597 }
5598 let path = cfg
5599 .state_dir
5600 .join("downloads")
5601 .join(brain)
5602 .join(transaction);
5603 crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5604 crate::fsx::open_directory_nofollow(&path)?;
5605 Ok(path)
5606}
5607
5608#[cfg(unix)]
5609fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5610 use std::os::fd::AsRawFd as _;
5611 let parent = cfg.state_dir.join("downloads").join(brain);
5612 let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5613 return;
5614 };
5615 let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5616 return;
5617 };
5618 let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5619 let _ = directory.sync_all();
5620}
5621
5622#[cfg(windows)]
5623fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5624 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5625 return;
5626 }
5627 let parent = cfg.state_dir.join("downloads").join(brain);
5628 let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5629 return;
5630 };
5631 let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5632}
5633
5634#[cfg(not(any(unix, windows)))]
5635fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5636
5637#[cfg(not(any(unix, windows)))]
5638fn v2_download_cache_dir_for(
5639 _cfg: &HubConfig,
5640 _brain: &str,
5641 _transaction: &str,
5642) -> LinkResult<PathBuf> {
5643 Err(LinkError::UnsupportedPlatform {
5644 operation: "resumable v2 download staging",
5645 })
5646}
5647
5648#[cfg(any(unix, windows))]
5649fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5650 let file = match crate::fsx::open_regular_nofollow(path) {
5651 Ok(file) => file,
5652 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5653 Err(error) => return Err(error.into()),
5654 };
5655 if file.metadata()?.len() != bytes {
5656 return Ok(false);
5657 }
5658 Ok(content_sha256_reader(file)? == sha256)
5659}
5660
5661#[cfg(any(unix, windows))]
5662fn cache_v2_blob_bytes(
5663 cache_dir: &Path,
5664 sha256: &str,
5665 expected_bytes: u64,
5666 bytes: &[u8],
5667) -> LinkResult<PathBuf> {
5668 if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5669 return Err(invalid_feed("v2 cached blob differs from its declaration"));
5670 }
5671 let path = cache_dir.join(sha256);
5672 if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5673 crate::fsx::write_atomic(&path, bytes)?;
5674 }
5675 Ok(path)
5676}
5677
5678#[cfg(not(any(unix, windows)))]
5679fn cache_v2_blob_bytes(
5680 _cache_dir: &Path,
5681 _sha256: &str,
5682 _expected_bytes: u64,
5683 _bytes: &[u8],
5684) -> LinkResult<PathBuf> {
5685 Err(LinkError::UnsupportedPlatform {
5686 operation: "resumable v2 download staging",
5687 })
5688}
5689
5690#[cfg(unix)]
5691fn download_presigned_to_cache(
5692 cfg: &HubConfig,
5693 url: &str,
5694 cache_dir: &Path,
5695 sha256: &str,
5696 expected_bytes: u64,
5697) -> LinkResult<PathBuf> {
5698 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5699
5700 let target = cache_dir.join(sha256);
5701 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5702 return Ok(target);
5703 }
5704 let directory = open_existing_dir_nofollow(cache_dir)?;
5705 let mut nonce = [0_u8; 16];
5706 ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5707 .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5708 let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5709 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5710 let fd = unsafe {
5711 libc::openat(
5712 directory.as_raw_fd(),
5713 temp.as_ptr(),
5714 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5715 0o600,
5716 )
5717 };
5718 if fd < 0 {
5719 return Err(std::io::Error::last_os_error().into());
5720 }
5721 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5722 let response = match presigned_agent(cfg, url)?.get(url).call() {
5723 Ok(response) => response,
5724 Err(ureq::Error::Status(_, response)) => {
5725 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5726 return Err(LinkError::Http {
5727 what: "v2 direct download",
5728 status: response.status(),
5729 message: "object store rejected the download".to_string(),
5730 code: None,
5731 details: None,
5732 });
5733 }
5734 Err(ureq::Error::Transport(error)) => {
5735 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5736 return Err(LinkError::Transport {
5737 hub: cfg.hub.clone(),
5738 message: error.to_string(),
5739 });
5740 }
5741 };
5742 let mut reader = response
5743 .into_reader()
5744 .take(expected_bytes.saturating_add(1));
5745 let mut digest = Sha256::new();
5746 let mut total = 0_u64;
5747 let mut buffer = [0_u8; 64 * 1024];
5748 let write_result = (|| -> LinkResult<()> {
5753 loop {
5754 let read = reader
5755 .read(&mut buffer)
5756 .map_err(|error| LinkError::Transport {
5757 hub: cfg.hub.clone(),
5758 message: error.to_string(),
5759 })?;
5760 if read == 0 {
5761 break;
5762 }
5763 total = total.saturating_add(read as u64);
5764 digest.update(&buffer[..read]);
5765 output.write_all(&buffer[..read])?;
5766 }
5767 output.sync_all().map_err(LinkError::from)
5768 })();
5769 if let Err(error) = write_result {
5770 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5771 return Err(error);
5772 }
5773 drop(output);
5774 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5775 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5776 return Err(invalid_feed(
5777 "v2 direct download failed integrity verification",
5778 ));
5779 }
5780 let target_name = c_name(sha256.as_bytes(), sha256)?;
5781 if unsafe {
5784 libc::renameat(
5785 directory.as_raw_fd(),
5786 temp.as_ptr(),
5787 directory.as_raw_fd(),
5788 target_name.as_ptr(),
5789 )
5790 } != 0
5791 {
5792 let error = std::io::Error::last_os_error();
5793 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5794 return Err(error.into());
5795 }
5796 directory.sync_all()?;
5797 Ok(target)
5798}
5799
5800#[cfg(windows)]
5801fn download_presigned_to_cache(
5802 cfg: &HubConfig,
5803 url: &str,
5804 cache_dir: &Path,
5805 sha256: &str,
5806 expected_bytes: u64,
5807) -> LinkResult<PathBuf> {
5808 use std::fs::OpenOptions;
5809
5810 let target = cache_dir.join(sha256);
5811 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5812 return Ok(target);
5813 }
5814 let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
5818 let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
5819 let mut output = OpenOptions::new()
5820 .write(true)
5821 .create_new(true)
5822 .open(&temp)?;
5823 let response = match presigned_agent(cfg, url)?.get(url).call() {
5824 Ok(response) => response,
5825 Err(ureq::Error::Status(_, response)) => {
5826 let _ = std::fs::remove_file(&temp);
5827 return Err(LinkError::Http {
5828 what: "v2 direct download",
5829 status: response.status(),
5830 message: "object store rejected the download".to_string(),
5831 code: None,
5832 details: None,
5833 });
5834 }
5835 Err(ureq::Error::Transport(error)) => {
5836 let _ = std::fs::remove_file(&temp);
5837 return Err(LinkError::Transport {
5838 hub: cfg.hub.clone(),
5839 message: error.to_string(),
5840 });
5841 }
5842 };
5843 let mut reader = response
5844 .into_reader()
5845 .take(expected_bytes.saturating_add(1));
5846 let mut digest = Sha256::new();
5847 let mut total = 0_u64;
5848 let mut buffer = [0_u8; 64 * 1024];
5849 let copied = (|| -> LinkResult<()> {
5851 loop {
5852 let read = reader
5853 .read(&mut buffer)
5854 .map_err(|error| LinkError::Transport {
5855 hub: cfg.hub.clone(),
5856 message: error.to_string(),
5857 })?;
5858 if read == 0 {
5859 break;
5860 }
5861 total = total.saturating_add(read as u64);
5862 digest.update(&buffer[..read]);
5863 output.write_all(&buffer[..read])?;
5864 }
5865 output.sync_all()?;
5866 Ok(())
5867 })();
5868 if let Err(error) = copied {
5869 let _ = std::fs::remove_file(&temp);
5870 return Err(error);
5871 }
5872 drop(output);
5873 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5874 let _ = std::fs::remove_file(&temp);
5875 return Err(invalid_feed(
5876 "v2 direct download failed integrity verification",
5877 ));
5878 }
5879 if target.exists() {
5880 std::fs::remove_file(&target)?;
5881 }
5882 if let Err(error) = std::fs::rename(&temp, &target) {
5883 let _ = std::fs::remove_file(&temp);
5884 return Err(error.into());
5885 }
5886 Ok(target)
5887}
5888
5889#[cfg(not(any(unix, windows)))]
5890fn download_presigned_to_cache(
5891 _cfg: &HubConfig,
5892 _url: &str,
5893 _cache_dir: &Path,
5894 _sha256: &str,
5895 _expected_bytes: u64,
5896) -> LinkResult<PathBuf> {
5897 Err(LinkError::UnsupportedPlatform {
5898 operation: "resumable v2 download staging",
5899 })
5900}
5901
5902fn download_v2_blobs(
5903 cfg: &HubConfig,
5904 brain: &str,
5905 pointer: &V2PointerBody,
5906 pending: Vec<(&String, &V2BaselineFile)>,
5907) -> LinkResult<Vec<(String, Vec<u8>)>> {
5908 if pending.is_empty() {
5909 return Ok(Vec::new());
5910 }
5911 let expected_order = pending
5912 .iter()
5913 .map(|(path, _)| (*path).clone())
5914 .collect::<Vec<_>>();
5915 let mut streamed = std::collections::BTreeMap::new();
5916 let mut direct = Vec::new();
5917 let mut window = Vec::new();
5918 let mut window_bytes = 0_u64;
5919 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5920 window_bytes: &mut u64,
5921 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
5922 -> LinkResult<()> {
5923 if window.is_empty() {
5924 return Ok(());
5925 }
5926 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
5927 if streamed.insert(path, bytes).is_some() {
5928 return Err(invalid_feed("v2 bulk streams repeated a path"));
5929 }
5930 }
5931 window.clear();
5932 *window_bytes = 0;
5933 Ok(())
5934 };
5935 for &(path, file) in &pending {
5936 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5937 flush(&mut window, &mut window_bytes, &mut streamed)?;
5938 direct.push((path, file));
5939 continue;
5940 }
5941 if window.len() == V2_BULK_STREAM_FILES
5942 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5943 {
5944 flush(&mut window, &mut window_bytes, &mut streamed)?;
5945 }
5946 window.push((path, file));
5947 window_bytes += file.bytes;
5948 }
5949 flush(&mut window, &mut window_bytes, &mut streamed)?;
5950
5951 let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
5952 let next = std::sync::atomic::AtomicUsize::new(0);
5953 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5954 let mut results = std::iter::repeat_with(|| None)
5955 .take(downloads.len())
5956 .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
5957 std::thread::scope(|scope| {
5958 let (sender, receiver) = std::sync::mpsc::channel();
5959 for _ in 0..worker_count {
5960 let sender = sender.clone();
5961 let downloads = &downloads;
5962 let next = &next;
5963 scope.spawn(move || loop {
5964 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5965 let Some(item) = downloads.get(index) else {
5966 break;
5967 };
5968 let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
5969 if sender.send((index, result)).is_err() {
5970 break;
5971 }
5972 });
5973 }
5974 drop(sender);
5975 for (index, result) in receiver {
5976 results[index] = Some(result);
5977 }
5978 });
5979 for result in results.into_iter().map(|result| {
5980 result.ok_or_else(|| LinkError::Transport {
5981 hub: cfg.hub.clone(),
5982 message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
5983 })?
5984 }) {
5985 let (path, bytes) = result?;
5986 if streamed.insert(path, bytes).is_some() {
5987 return Err(invalid_feed("v2 download lanes repeated a path"));
5988 }
5989 }
5990 expected_order
5991 .into_iter()
5992 .map(|path| {
5993 streamed
5994 .remove(&path)
5995 .map(|bytes| (path, bytes))
5996 .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
5997 })
5998 .collect()
5999}
6000
6001#[cfg(any(unix, windows))]
6005fn stage_v2_blobs(
6006 cfg: &HubConfig,
6007 brain: &str,
6008 pointer: &V2PointerBody,
6009 pending: Vec<(&String, &V2BaselineFile)>,
6010) -> LinkResult<Vec<V2StagedFile>> {
6011 let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6012 let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6013 let mut direct = Vec::new();
6014 let mut window = Vec::new();
6015 let mut window_bytes = 0_u64;
6016 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6017 window_bytes: &mut u64,
6018 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
6019 -> LinkResult<()> {
6020 if window.is_empty() {
6021 return Ok(());
6022 }
6023 let missing = window
6024 .iter()
6025 .filter_map(|(path, file)| {
6026 let target = cache_dir.join(&file.sha256);
6027 match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6028 Ok(true) => {
6029 staged.insert(
6030 (*path).clone(),
6031 V2StagedFile {
6032 path: (*path).clone(),
6033 source: target,
6034 sha256: file.sha256.clone(),
6035 bytes: file.bytes,
6036 },
6037 );
6038 None
6039 }
6040 Ok(false) => Some(Ok((*path, *file))),
6041 Err(error) => Some(Err(error)),
6042 }
6043 })
6044 .collect::<LinkResult<Vec<_>>>()?;
6045 if !missing.is_empty() {
6046 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
6047 let file = missing
6048 .iter()
6049 .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
6050 .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
6051 let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
6052 staged.insert(
6053 path.clone(),
6054 V2StagedFile {
6055 path,
6056 source,
6057 sha256: file.sha256.clone(),
6058 bytes: file.bytes,
6059 },
6060 );
6061 }
6062 }
6063 window.clear();
6064 *window_bytes = 0;
6065 Ok(())
6066 };
6067 for &(path, file) in &pending {
6068 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6069 flush(&mut window, &mut window_bytes, &mut staged)?;
6070 direct.push((path, file));
6071 continue;
6072 }
6073 if window.len() == V2_BULK_STREAM_FILES
6074 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6075 {
6076 flush(&mut window, &mut window_bytes, &mut staged)?;
6077 }
6078 window.push((path, file));
6079 window_bytes += file.bytes;
6080 }
6081 flush(&mut window, &mut window_bytes, &mut staged)?;
6082 for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6083 let source =
6084 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6085 staged.insert(
6086 item.path.clone(),
6087 V2StagedFile {
6088 path: item.path,
6089 source,
6090 sha256: item.sha256,
6091 bytes: item.bytes,
6092 },
6093 );
6094 }
6095 pending
6096 .into_iter()
6097 .map(|(path, _)| {
6098 staged
6099 .remove(path)
6100 .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6101 })
6102 .collect()
6103}
6104
6105#[cfg(not(any(unix, windows)))]
6106fn stage_v2_blobs(
6107 _cfg: &HubConfig,
6108 _brain: &str,
6109 _pointer: &V2PointerBody,
6110 _pending: Vec<(&String, &V2BaselineFile)>,
6111) -> LinkResult<Vec<V2StagedFile>> {
6112 Err(LinkError::UnsupportedPlatform {
6113 operation: "resumable v2 download staging",
6114 })
6115}
6116
6117const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6118const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6119const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6120
6121#[derive(Debug, Clone, Deserialize, Serialize)]
6122struct V2ConflictCoordinate {
6123 sha256: Option<String>,
6124 bytes: Option<u64>,
6125 file: Option<String>,
6126}
6127
6128#[derive(Debug, Clone, Deserialize, Serialize)]
6129struct V2ConflictFile {
6130 path: String,
6131 base: V2ConflictCoordinate,
6132 local: V2ConflictCoordinate,
6133 remote: V2ConflictCoordinate,
6134}
6135
6136#[derive(Debug, Clone, Deserialize, Serialize)]
6137struct V2ConflictPlan {
6138 v: u8,
6139 class: String,
6140 bundle: String,
6141 brain: String,
6142 origin: String,
6143 created_unix: u64,
6144 expires_unix: u64,
6145 base_seq: Option<u64>,
6146 base_commit: Option<String>,
6147 remote_seq: u64,
6148 remote_commit: Option<String>,
6149 remote_content_root: Option<String>,
6150 view_kind: String,
6151 view_revision: String,
6152 files: Vec<V2ConflictFile>,
6153}
6154
6155fn v2_take_remote_selection(
6156 files: &[V2ConflictFile],
6157 current: &std::collections::BTreeMap<String, V2BaselineFile>,
6158) -> LinkResult<(
6159 std::collections::BTreeMap<String, V2BaselineFile>,
6160 Vec<String>,
6161)> {
6162 let mut selected = std::collections::BTreeMap::new();
6163 let mut deleted = Vec::new();
6164 for file in files {
6165 match (&file.remote.sha256, file.remote.bytes) {
6166 (Some(sha256), Some(bytes)) => {
6167 let proven = current.get(&file.path).ok_or_else(|| {
6168 invalid_feed("conflict remote coordinate disappeared from the exact head")
6169 })?;
6170 if proven.sha256 != *sha256 || proven.bytes != bytes {
6171 return Err(invalid_feed(
6172 "conflict remote coordinate differs from the exact head",
6173 ));
6174 }
6175 if selected.insert(file.path.clone(), proven.clone()).is_some() {
6176 return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6177 }
6178 }
6179 (None, None) => {
6180 if current.contains_key(&file.path) {
6181 return Err(invalid_feed(
6182 "conflict remote deletion differs from the exact head",
6183 ));
6184 }
6185 deleted.push(file.path.clone());
6186 }
6187 _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6188 }
6189 }
6190 Ok((selected, deleted))
6191}
6192
6193fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6194 PathBuf::from(".dbmd")
6195 .join("conflicts")
6196 .join(bundle)
6197 .join(suffix)
6198}
6199
6200fn read_historical_conflict_blob(
6201 cfg: &HubConfig,
6202 brain: &str,
6203 baseline: &V2SyncBaseline,
6204 path: &str,
6205 file: &V2BaselineFile,
6206) -> LinkResult<Option<Vec<u8>>> {
6207 let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6208 return Ok(None);
6209 };
6210 if seq == 0 {
6211 return Ok(None);
6212 }
6213 let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6214 let endpoint = format!(
6215 "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6216 file.sha256
6217 );
6218 let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
6219 if raw.status == 404 || raw.status == 403 {
6220 return Ok(None);
6221 }
6222 let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6223 if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6224 return Err(invalid_feed(
6225 "v2 conflict base failed integrity verification",
6226 ));
6227 }
6228 Ok(Some(bytes))
6229}
6230
6231fn create_v2_conflict_bundle(
6234 cfg: &HubConfig,
6235 store: &Store,
6236 head: &V2VerifiedHead,
6237 baseline: Option<&V2SyncBaseline>,
6238 local: &std::collections::BTreeMap<String, (String, u64)>,
6239 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6240 paths: &[String],
6241) -> LinkResult<(String, Vec<String>)> {
6242 let conflicts_root = Path::new(".dbmd/conflicts");
6243 store.create_dir_all(conflicts_root)?;
6244 let completed = store
6245 .directory_names(conflicts_root)?
6246 .into_iter()
6247 .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6248 .count();
6249 if completed >= V2_CONFLICT_BUNDLE_MAX {
6250 return Err(LinkError::InvalidPack {
6251 message: format!(
6252 "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6253 ),
6254 });
6255 }
6256
6257 let mut selected_paths = Vec::new();
6261 let mut selected_remote_bytes = 0_u64;
6262 for path in paths {
6263 let bytes = remote.get(path).map_or(0, |file| file.bytes);
6264 if !selected_paths.is_empty()
6265 && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6266 {
6267 break;
6268 }
6269 selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6270 selected_paths.push(path.clone());
6271 if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6272 break;
6273 }
6274 }
6275 if selected_paths.is_empty() {
6276 return Err(invalid_feed("content conflict set is empty"));
6277 }
6278 let bundle = crate::ulid::mint();
6279 let bundle_root = v2_conflict_relative(&bundle, "");
6280 store.create_dir_all(&bundle_root.join("files"))?;
6281 let pointer = head.pointer.as_ref();
6282 let remote_bytes = match pointer {
6283 Some(pointer) => download_v2_blobs(
6284 cfg,
6285 &head.brain_id,
6286 pointer,
6287 selected_paths
6288 .iter()
6289 .filter_map(|path| {
6290 remote
6291 .get(path)
6292 .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6293 .map(|file| (path, file))
6294 })
6295 .collect(),
6296 )?
6297 .into_iter()
6298 .collect::<std::collections::BTreeMap<_, _>>(),
6299 None => std::collections::BTreeMap::new(),
6300 };
6301
6302 let mut files = Vec::with_capacity(selected_paths.len());
6303 for (index, path) in selected_paths.iter().enumerate() {
6304 let base_file = baseline.and_then(|state| state.files.get(path));
6305 let base_bytes = match (baseline, base_file) {
6306 (Some(state), Some(file)) => {
6307 read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
6308 }
6309 _ => None,
6310 };
6311 let local_file = local.get(path);
6312 let remote_file = remote.get(path);
6313 let remote_content = remote_bytes.get(path);
6314 let prefix = format!("files/{index:04}");
6315 let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6316 let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6317 let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6318 if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6319 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6320 }
6321 if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6322 let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6323 if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6324 return Err(LinkError::InvalidPack {
6325 message: format!("local conflict path `{path}` changed while bundling"),
6326 });
6327 }
6328 store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6329 }
6330 if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6331 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6332 }
6333 files.push(V2ConflictFile {
6334 path: path.clone(),
6335 base: V2ConflictCoordinate {
6336 sha256: base_file.map(|file| file.sha256.clone()),
6337 bytes: base_file.map(|file| file.bytes),
6338 file: base_name,
6339 },
6340 local: V2ConflictCoordinate {
6341 sha256: local_file.map(|(sha256, _)| sha256.clone()),
6342 bytes: local_file.map(|(_, bytes)| *bytes),
6343 file: local_name,
6344 },
6345 remote: V2ConflictCoordinate {
6346 sha256: remote_file.map(|file| file.sha256.clone()),
6347 bytes: remote_file.map(|file| file.bytes),
6348 file: remote_name,
6349 },
6350 });
6351 }
6352 let now = SystemTime::now()
6353 .duration_since(UNIX_EPOCH)
6354 .unwrap_or_default()
6355 .as_secs();
6356 let plan = V2ConflictPlan {
6357 v: 2,
6358 class: "content_resolution_required".to_string(),
6359 bundle: bundle.clone(),
6360 brain: head.brain_id.clone(),
6361 origin: normalized_origin(&cfg.hub)?,
6362 created_unix: now,
6363 expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6364 base_seq: baseline.and_then(|state| state.head_seq),
6365 base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6366 remote_seq: pointer.map_or(0, |value| value.seq),
6367 remote_commit: pointer.map(|value| value.commit_hash.clone()),
6368 remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6369 view_kind: head.view_kind.clone(),
6370 view_revision: head.view_revision.clone(),
6371 files,
6372 };
6373 let mut bytes = serde_json::to_vec_pretty(&plan)
6374 .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6375 bytes.push(b'\n');
6376 store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6377 Ok((bundle, selected_paths))
6378}
6379
6380fn v2_sync_pull_with_resolution(
6381 cfg: &HubConfig,
6382 requested_brain: &str,
6383 expected_head: V2VerifiedHead,
6384 out: Option<&Path>,
6385 take_remote: Option<&std::collections::BTreeSet<String>>,
6386) -> LinkResult<V2PulledSnapshot> {
6387 let dest = out
6388 .map(Path::to_path_buf)
6389 .unwrap_or_else(|| PathBuf::from(requested_brain));
6390 let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6391 recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6392 let head = v2_verified_head(cfg, requested_brain)?
6393 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6394 if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6395 return Err(LinkError::RemoteAdvancedDuringSync);
6396 }
6397 let remote = files_for_v2_view(
6398 &head,
6399 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6400 );
6401 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
6402 let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6403 ensure_v2_view_compatible(&head, baseline.as_ref())?;
6404 let local_store = Store::open_strict(&dest).ok();
6405 ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6410 let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6411 if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6412 return Err(LinkError::ScopedViewChanged);
6413 }
6414 if let Some(view) = local_view.as_mut() {
6415 remove_scoped_projection(&head, baseline.as_ref(), view)?;
6416 }
6417 let empty_local = std::collections::BTreeMap::new();
6418 let local = local_view
6419 .as_ref()
6420 .map_or(&empty_local, |view| &view.riding);
6421 let kept_home = |path: &str| {
6422 local_view
6423 .as_ref()
6424 .is_some_and(|view| view.policy.keeps_home(path))
6425 };
6426 let empty_base = std::collections::BTreeMap::new();
6427 let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6428 let empty_base_assets = std::collections::BTreeMap::new();
6429 let base_assets = baseline
6430 .as_ref()
6431 .map_or(&empty_base_assets, |state| &state.assets);
6432 let mut local_assets = local_store
6433 .as_ref()
6434 .map(v2_local_asset_records)
6435 .transpose()?
6436 .unwrap_or_default();
6437 let mut content_merge = merge_v2_pulled_records(
6438 base,
6439 &remote,
6440 local,
6441 |file, _| (file.sha256.clone(), file.bytes),
6442 |file, _| (file.sha256.clone(), file.bytes),
6443 kept_home,
6444 );
6445 if let Some(selected) = take_remote {
6446 for path in selected {
6447 if let Some(position) = content_merge
6448 .conflicts
6449 .iter()
6450 .position(|conflict| conflict == path)
6451 {
6452 content_merge.conflicts.remove(position);
6453 content_merge.accept_remote.insert(path.clone());
6454 match remote.get(path) {
6455 Some(file) => {
6456 content_merge
6457 .records
6458 .insert(path.clone(), (file.sha256.clone(), file.bytes));
6459 }
6460 None => {
6461 content_merge.records.remove(path);
6462 }
6463 }
6464 } else if !content_merge.accept_remote.contains(path) {
6465 return Err(LinkError::InvalidPack {
6466 message: format!(
6467 "take-remote path `{path}` is no longer at its conflict coordinate"
6468 ),
6469 });
6470 }
6471 }
6472 }
6473 if !content_merge.conflicts.is_empty() {
6474 let mut conflicts = content_merge.conflicts.clone();
6475 conflicts.truncate(100);
6476 if let Some(store) = local_store.as_ref() {
6477 let (bundle, paths) = create_v2_conflict_bundle(
6478 cfg,
6479 store,
6480 &head,
6481 baseline.as_ref(),
6482 local,
6483 &remote,
6484 &conflicts,
6485 )?;
6486 return Err(LinkError::ConflictBundle { bundle, paths });
6487 }
6488 return Err(LinkError::Conflict { paths: conflicts });
6489 }
6490 let asset_merge = merge_v2_pulled_records(
6491 base_assets,
6492 &remote_assets,
6493 &local_assets,
6494 v2_asset_record,
6495 v2_asset_record,
6496 |_| false,
6497 );
6498 if !asset_merge.conflicts.is_empty() {
6499 let mut conflicts = asset_merge.conflicts.clone();
6500 conflicts.truncate(100);
6501 return Err(LinkError::Conflict { paths: conflicts });
6502 }
6503 let pointer = head.pointer.as_ref();
6504 let cache_transaction = pointer.map_or_else(
6505 || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6506 |value| value.commit_hash.clone(),
6507 );
6508 let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6509 let mut changed = match pointer {
6510 Some(pointer) => stage_v2_blobs(
6511 cfg,
6512 &head.brain_id,
6513 pointer,
6514 remote
6515 .iter()
6516 .filter(|(path, file)| {
6517 content_merge.accept_remote.contains(*path)
6518 && local.get(*path).map(|value| value.0.as_str())
6519 != Some(file.sha256.as_str())
6520 })
6521 .collect(),
6522 )?,
6523 None => Vec::new(),
6524 };
6525 let mut deleted = content_merge
6526 .accept_remote
6527 .iter()
6528 .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6529 .cloned()
6530 .collect::<Vec<_>>();
6531 if local_assets != asset_merge.records {
6532 if asset_merge.records.is_empty() {
6533 deleted.push("assets.jsonl".to_string());
6534 } else {
6535 let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6536 let sha256 = content_sha256(&bytes);
6537 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6538 changed.push(V2StagedFile {
6539 path: "assets.jsonl".to_string(),
6540 source,
6541 sha256,
6542 bytes: bytes.len() as u64,
6543 });
6544 }
6545 }
6546 if let Some(pointer) = pointer {
6547 let mut pending_assets = Vec::new();
6548 for (path, asset) in &remote_assets {
6549 if asset.disposition != "hosted"
6550 || kept_home(path)
6551 || !asset_merge.accept_remote.contains(path)
6552 {
6553 continue;
6554 }
6555 let already_current = local_store.as_ref().is_some_and(|store| {
6556 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6557 && store
6558 .read_bounded(Path::new(path), asset.bytes)
6559 .ok()
6560 .is_some_and(|bytes| {
6561 bytes.len() as u64 == asset.bytes
6562 && content_sha256(&bytes) == asset.blob_sha256
6563 })
6564 });
6565 if !already_current {
6566 pending_assets.push((path, asset));
6567 }
6568 }
6569 for item in prepare_v2_asset_downloads(cfg, &head.brain_id, pointer, &pending_assets)? {
6570 let source =
6571 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6572 changed.push(V2StagedFile {
6573 path: item.path,
6574 source,
6575 sha256: item.sha256,
6576 bytes: item.bytes,
6577 });
6578 }
6579 }
6580 for (path, prior) in base_assets {
6581 if remote_assets.contains_key(path)
6582 || kept_home(path)
6583 || !asset_merge.accept_remote.contains(path)
6584 {
6585 continue;
6586 }
6587 let unchanged = local_store.as_ref().is_some_and(|store| {
6588 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6589 && store
6590 .read_bounded(Path::new(path), prior.bytes)
6591 .ok()
6592 .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6593 });
6594 if unchanged {
6595 deleted.push(path.clone());
6596 }
6597 }
6598 let extra_local = content_merge
6599 .records
6600 .keys()
6601 .filter(|path| !remote.contains_key(*path))
6602 .cloned()
6603 .collect::<Vec<_>>();
6604 if head.view_kind == "scoped" {
6605 for (path, bytes) in [
6606 ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6607 (
6608 ".dbmd/view.json".to_string(),
6609 scoped_view_metadata(&head, remote.len())?,
6610 ),
6611 ] {
6612 let sha256 = content_sha256(&bytes);
6613 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6614 changed.push(V2StagedFile {
6615 path,
6616 source,
6617 sha256,
6618 bytes: bytes.len() as u64,
6619 });
6620 }
6621 }
6622 let install_changed = !changed.is_empty() || !deleted.is_empty();
6623 install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6624 let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6625 let installed_store =
6626 Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6627 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6628 })?;
6629 let installed_local = if install_changed {
6630 let mut scanned = v2_local_files(&installed_store)?;
6631 remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6632 scanned
6633 } else {
6634 local_view
6635 .take()
6636 .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6637 };
6638 if installed_local.riding != content_merge.records {
6639 return Err(LinkError::InvalidPack {
6640 message: "local content changed while installing the v2 pull".to_string(),
6641 });
6642 }
6643 let installed_assets = if install_changed {
6644 v2_local_asset_records(&installed_store)?
6645 } else {
6646 std::mem::take(&mut local_assets)
6647 };
6648 if installed_assets != asset_merge.records {
6649 return Err(LinkError::InvalidPack {
6650 message: "local assets changed while installing the v2 pull".to_string(),
6651 });
6652 }
6653 let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6654 installed_local.policy.keeps_home(path)
6655 })
6656 || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6657 let final_head = v2_verified_head(cfg, requested_brain)?
6658 .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6659 if !same_v2_head(&head, &final_head) {
6660 return Err(LinkError::RemoteAdvancedDuringSync);
6661 }
6662 accept_v2_head(cfg, &final_head)?;
6663 save_v2_baseline(
6664 cfg,
6665 &head.brain_id,
6666 &dest,
6667 &v2_baseline_from_head(
6668 cfg,
6669 &head,
6670 remote.clone(),
6671 remote_assets.clone(),
6672 Some(&installed_local),
6673 baseline
6674 .as_ref()
6675 .and_then(|current| current.checkout_id.as_deref()),
6676 )?,
6677 )?;
6678 complete_v2_pull(&dest)?;
6679 Ok((local_dirty, installed_local, installed_assets))
6680 })();
6681 let (local_dirty, installed_local, installed_assets) = match finalized {
6682 Ok(value) => value,
6683 Err(error) => {
6684 if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6685 return Err(LinkError::InvalidPack {
6686 message: format!("{error}; durable pull recovery also failed: {recovery}"),
6687 });
6688 }
6689 return Err(error);
6690 }
6691 };
6692 cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6693 let report = PullReport {
6694 brain: head.brain_id.clone(),
6695 slug: requested_brain.to_string(),
6696 head_seq: pointer.map_or(0, |value| value.seq),
6697 files: remote.len() + remote_assets.len(),
6698 dest: dest.to_string_lossy().into_owned(),
6699 extra_local,
6700 sync_status: if local_dirty {
6701 "local_dirty_after_install".to_string()
6702 } else {
6703 "synced".to_string()
6704 },
6705 };
6706 Ok(V2PulledSnapshot {
6707 report,
6708 head,
6709 files: remote,
6710 assets: remote_assets,
6711 local: installed_local,
6712 local_assets: installed_assets,
6713 })
6714}
6715
6716fn v2_sync_pull(
6717 cfg: &HubConfig,
6718 requested_brain: &str,
6719 head: V2VerifiedHead,
6720 out: Option<&Path>,
6721) -> LinkResult<PullReport> {
6722 Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
6723}
6724
6725fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
6726 match remote {
6727 Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
6728 None => json!({ "kind": "absent" }),
6729 }
6730}
6731
6732fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
6733 match remote {
6734 Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
6735 None => json!({ "kind": "absent" }),
6736 }
6737}
6738
6739fn v2_content_withdrawal_operation(
6740 store: &Store,
6741 local_view: &V2LocalView,
6742 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6743 path: &str,
6744 reason: &str,
6745) -> LinkResult<Value> {
6746 if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
6747 || path == "DB.md"
6748 {
6749 return Err(LinkError::InvalidPack {
6750 message: format!("content withdrawal path `{path}` is not a record or source"),
6751 });
6752 }
6753 if !local_view.policy.keeps_home(path)
6754 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6755 {
6756 return Err(LinkError::InvalidPack {
6757 message: format!(
6758 "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6759 ),
6760 });
6761 }
6762 let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
6763 message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
6764 })?;
6765 verify_v2_upload_source(store, path, ¤t.sha256, current.bytes)?;
6766 Ok(json!({
6767 "op": "withdraw_from_hosting",
6768 "path": path,
6769 "expected": { "kind": "blob", "hash": current.sha256 },
6770 "reason": reason,
6771 }))
6772}
6773
6774fn v2_asset_withdrawal_operation(
6775 store: &Store,
6776 local_view: &V2LocalView,
6777 path: &str,
6778 local: &crate::AssetRecord,
6779 current: &V2BaselineAsset,
6780 reason: &str,
6781) -> LinkResult<Value> {
6782 if !local_view.policy.keeps_home(path)
6783 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6784 {
6785 return Err(LinkError::InvalidPack {
6786 message: format!(
6787 "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6788 ),
6789 });
6790 }
6791 verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
6792 if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
6793 return Err(LinkError::InvalidPack {
6794 message: format!(
6795 "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
6796 ),
6797 });
6798 }
6799 Ok(json!({
6800 "op": "asset_withdraw",
6801 "path": path,
6802 "expected": v2_asset_expected(Some(current)),
6803 "reason": reason,
6804 }))
6805}
6806
6807fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
6814 let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
6815 let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
6816 for (index, operation) in operations.iter().enumerate() {
6817 match operation.get("op").and_then(Value::as_str) {
6818 Some("delete") => {
6819 let Some(path) = operation.get("path").and_then(Value::as_str) else {
6820 continue;
6821 };
6822 let Some(hash) = operation
6823 .get("expected")
6824 .and_then(|value| value.get("hash"))
6825 .and_then(Value::as_str)
6826 else {
6827 continue;
6828 };
6829 if path.starts_with("sources/") {
6830 deletes
6831 .entry(hash.to_string())
6832 .or_default()
6833 .push((index, path.to_string()));
6834 }
6835 }
6836 Some("put") => {
6837 let Some(path) = operation.get("path").and_then(Value::as_str) else {
6838 continue;
6839 };
6840 let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
6841 continue;
6842 };
6843 let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
6844 continue;
6845 };
6846 let destination_absent = operation
6847 .get("expected")
6848 .and_then(|value| value.get("kind"))
6849 .and_then(Value::as_str)
6850 == Some("absent");
6851 if path.starts_with("sources/") && destination_absent {
6852 puts.entry(hash.to_string()).or_default().push((
6853 index,
6854 path.to_string(),
6855 bytes,
6856 ));
6857 }
6858 }
6859 _ => {}
6860 }
6861 }
6862 let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
6863 let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
6864 for (hash, source) in deletes {
6865 let Some(destination) = puts.get(&hash) else {
6866 continue;
6867 };
6868 if source.len() != 1 || destination.len() != 1 {
6869 continue;
6870 }
6871 let (delete_index, from) = &source[0];
6872 let (put_index, to, bytes) = &destination[0];
6873 if from == to {
6874 continue;
6875 }
6876 rename_at.insert(
6877 *delete_index,
6878 json!({
6879 "op": "rename",
6880 "from": from,
6881 "to": to,
6882 "expected_from": { "kind": "blob", "hash": hash },
6883 "expected_to": { "kind": "absent" },
6884 "blob": hash,
6885 "bytes": bytes,
6886 }),
6887 );
6888 consumed_puts.insert(*put_index);
6889 }
6890 operations
6891 .into_iter()
6892 .enumerate()
6893 .filter_map(|(index, operation)| {
6894 if let Some(rename) = rename_at.remove(&index) {
6895 Some(rename)
6896 } else if consumed_puts.contains(&index) {
6897 None
6898 } else {
6899 Some(operation)
6900 }
6901 })
6902 .collect()
6903}
6904
6905fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
6906 json!({
6907 "blob_sha256": record.sha256,
6908 "bytes": record.bytes,
6909 "media_type": record.media_type,
6910 "wrappers": record.wrappers,
6911 "required": record.required,
6912 "disposition": disposition,
6913 })
6914}
6915
6916fn apply_generated_v2_operations(
6920 operations: &[Value],
6921 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
6922 candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
6923 candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
6924) -> LinkResult<bool> {
6925 let mut asset_changed = false;
6926 for operation in operations {
6927 match operation.get("op").and_then(Value::as_str) {
6928 Some("put") => {
6929 let path = operation
6930 .get("path")
6931 .and_then(Value::as_str)
6932 .ok_or_else(|| invalid_feed("v2 put has no path"))?;
6933 let sha256 = operation
6934 .get("blob")
6935 .and_then(Value::as_str)
6936 .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
6937 let bytes = operation
6938 .get("bytes")
6939 .and_then(Value::as_u64)
6940 .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
6941 candidate.insert(
6942 path.to_string(),
6943 V2BaselineFile {
6944 sha256: sha256.to_string(),
6945 bytes,
6946 proof: None,
6947 },
6948 );
6949 }
6950 Some("rename") => {
6951 let from = operation
6952 .get("from")
6953 .and_then(Value::as_str)
6954 .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
6955 let to = operation
6956 .get("to")
6957 .and_then(Value::as_str)
6958 .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
6959 let sha256 = operation
6960 .get("blob")
6961 .and_then(Value::as_str)
6962 .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
6963 let bytes = operation
6964 .get("bytes")
6965 .and_then(Value::as_u64)
6966 .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
6967 let expected_from = operation
6968 .get("expected_from")
6969 .and_then(|expected| expected.get("hash"))
6970 .and_then(Value::as_str);
6971 let expected_to_absent = operation
6972 .get("expected_to")
6973 .and_then(|expected| expected.get("kind"))
6974 .and_then(Value::as_str)
6975 == Some("absent");
6976 if from == to
6977 || !from.starts_with("sources/")
6978 || !to.starts_with("sources/")
6979 || expected_from != Some(sha256)
6980 || !expected_to_absent
6981 || candidate.contains_key(to)
6982 {
6983 return Err(invalid_feed("generated v2 source rename is malformed"));
6984 }
6985 let source = candidate
6986 .remove(from)
6987 .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
6988 if source.sha256 != sha256 || source.bytes != bytes {
6989 return Err(invalid_feed(
6990 "v2 rename source differs from its exact-byte claim",
6991 ));
6992 }
6993 candidate.insert(
6994 to.to_string(),
6995 V2BaselineFile {
6996 sha256: sha256.to_string(),
6997 bytes,
6998 proof: None,
6999 },
7000 );
7001 }
7002 Some("delete" | "withdraw_from_hosting") => {
7003 let path = operation
7004 .get("path")
7005 .and_then(Value::as_str)
7006 .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7007 candidate.remove(path);
7008 }
7009 Some("asset_delete") => {
7010 let path = operation
7011 .get("path")
7012 .and_then(Value::as_str)
7013 .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7014 candidate_assets.remove(path);
7015 asset_changed = true;
7016 }
7017 Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7018 let path = operation
7019 .get("path")
7020 .and_then(Value::as_str)
7021 .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7022 let record = local_assets
7023 .get(path)
7024 .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7025 let disposition =
7026 if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7027 "withheld"
7028 } else {
7029 operation
7030 .get("asset")
7031 .and_then(|asset| asset.get("disposition"))
7032 .and_then(Value::as_str)
7033 .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7034 };
7035 candidate_assets.insert(
7036 path.to_string(),
7037 V2BaselineAsset {
7038 blob_sha256: record.sha256.clone(),
7039 bytes: record.bytes,
7040 media_type: record.media_type.clone(),
7041 wrappers: record.wrappers.clone(),
7042 required: record.required,
7043 disposition: disposition.to_string(),
7044 leaf_hash: String::new(),
7047 },
7048 );
7049 asset_changed = true;
7050 }
7051 _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7052 }
7053 }
7054 Ok(asset_changed)
7055}
7056
7057fn v2_riding_matches_remote(
7058 local: &std::collections::BTreeMap<String, (String, u64)>,
7059 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7060 keeps_home: impl Fn(&str) -> bool,
7061) -> bool {
7062 remote.iter().all(|(path, file)| {
7063 keeps_home(path)
7064 || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7065 }) && local.iter().all(|(path, (hash, _))| {
7066 remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7067 })
7068}
7069
7070#[derive(Debug, Clone)]
7071struct V2ResolutionOverride {
7072 expected_remote: Option<String>,
7073 selected_local: Option<String>,
7074}
7075
7076#[derive(Debug, Clone)]
7077struct V2UploadSource {
7078 path: String,
7079 bytes: u64,
7080}
7081
7082struct V2SyncPushOptions<'a> {
7083 resume_local_policy: bool,
7084 bulk_confirmation: Option<&'a V2BulkConfirmation>,
7085 resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7086 pulled: Option<V2PulledSnapshot>,
7087 withdrawal_paths: &'a [String],
7088 withdrawal_reason: Option<&'a str>,
7089}
7090
7091fn verify_v2_upload_source(
7092 store: &Store,
7093 path: &str,
7094 sha256: &str,
7095 expected_bytes: u64,
7096) -> LinkResult<()> {
7097 let file = store.open_regular(Path::new(path))?;
7098 if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7099 return Err(LinkError::InvalidPack {
7100 message: format!("local path `{path}` changed during sync planning"),
7101 });
7102 }
7103 Ok(())
7104}
7105
7106struct V2PendingUpload<'a> {
7109 url: String,
7110 headers: Value,
7111 sha256: String,
7112 source: &'a V2UploadSource,
7113}
7114
7115const V2_UPLOAD_CONCURRENCY: usize = 16;
7122
7123fn upload_v2_batch_concurrently(
7127 cfg: &HubConfig,
7128 store: &Store,
7129 pending: &[V2PendingUpload<'_>],
7130) -> LinkResult<()> {
7131 if pending.is_empty() {
7132 return Ok(());
7133 }
7134 let urls = pending
7135 .iter()
7136 .map(|task| task.url.as_str())
7137 .collect::<Vec<_>>();
7138 let shared = shared_staging_agent(cfg, &urls);
7139 if pending.len() == 1 {
7140 let task = &pending[0];
7141 put_presigned_source(
7142 cfg,
7143 &task.url,
7144 &task.headers,
7145 store,
7146 task.source,
7147 shared.as_ref(),
7148 )?;
7149 return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7150 }
7151 let next = std::sync::atomic::AtomicUsize::new(0);
7152 let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7153 let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7154 std::thread::scope(|scope| {
7155 for _ in 0..workers {
7156 scope.spawn(|| loop {
7157 if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7158 return;
7159 }
7160 let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7161 let Some(task) = pending.get(index) else {
7162 return;
7163 };
7164 let outcome = put_presigned_source(
7165 cfg,
7166 &task.url,
7167 &task.headers,
7168 store,
7169 task.source,
7170 shared.as_ref(),
7171 )
7172 .and_then(|()| {
7173 verify_v2_upload_source(
7174 store,
7175 &task.source.path,
7176 &task.sha256,
7177 task.source.bytes,
7178 )
7179 });
7180 if let Err(error) = outcome {
7181 if let Ok(mut guard) = failure.lock() {
7182 guard.get_or_insert(error);
7183 }
7184 return;
7185 }
7186 });
7187 }
7188 });
7189 match failure.into_inner() {
7190 Ok(Some(error)) => Err(error),
7191 Ok(None) => Ok(()),
7192 Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7193 }
7194}
7195
7196fn put_presigned_source(
7197 cfg: &HubConfig,
7198 raw: &str,
7199 headers: &Value,
7200 store: &Store,
7201 source: &V2UploadSource,
7202 shared: Option<&ureq::Agent>,
7203) -> LinkResult<()> {
7204 put_presigned_source_with_budget(
7205 cfg,
7206 raw,
7207 headers,
7208 store,
7209 source,
7210 shared,
7211 std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
7212 )
7213}
7214
7215fn put_presigned_source_with_budget(
7216 cfg: &HubConfig,
7217 raw: &str,
7218 headers: &Value,
7219 store: &Store,
7220 source: &V2UploadSource,
7221 shared: Option<&ureq::Agent>,
7222 total_budget: std::time::Duration,
7223) -> LinkResult<()> {
7224 let owned = match shared {
7227 Some(_) => {
7228 checked_presigned_url(cfg, raw)?;
7229 None
7230 }
7231 None => Some(presigned_agent(cfg, raw)?),
7232 };
7233 let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7234 let deadline = std::time::Instant::now()
7235 .checked_add(total_budget)
7236 .ok_or_else(upload_deadline_error)?;
7237 let mut attempt = 0;
7238 let result = loop {
7239 let file = store.open_regular(Path::new(&source.path))?;
7240 if file.metadata()?.len() != source.bytes {
7241 return Err(LinkError::InvalidPack {
7242 message: format!("local path `{}` changed before upload", source.path),
7243 });
7244 }
7245 let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
7250 let mut has_content_length = false;
7251 if let Some(map) = headers.as_object() {
7252 for (name, value) in map {
7253 if let Some(value) = value.as_str() {
7254 has_content_length |= name.eq_ignore_ascii_case("content-length");
7255 req = req.set(name, value);
7256 }
7257 }
7258 }
7259 if !has_content_length {
7260 req = req.set("Content-Length", &source.bytes.to_string());
7261 }
7262 match req.send(file) {
7263 Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
7269 attempt += 1;
7270 }
7271 Err(ureq::Error::Status(status, _))
7277 if status != 412
7278 && is_retryable_upload_status(status)
7279 && wait_for_upload_retry(deadline, attempt) =>
7280 {
7281 attempt += 1;
7282 }
7283 result => break result,
7284 }
7285 };
7286 match result {
7287 Ok(response) if (200..300).contains(&response.status()) => {
7288 drain_presigned_response(response);
7289 Ok(())
7290 }
7291 Ok(response) => {
7292 let status = response.status();
7297 let detail = response
7298 .into_string()
7299 .ok()
7300 .map(|body| body.chars().take(400).collect::<String>())
7301 .filter(|body| !body.trim().is_empty());
7302 Err(LinkError::Http {
7303 what: "v2 changed-byte upload",
7304 status,
7305 message: match detail {
7306 Some(body) => format!(
7307 "object store rejected the upload of `{}`: {}",
7308 source.path,
7309 body.replace('\n', " ")
7310 ),
7311 None => format!("object store rejected the upload of `{}`", source.path),
7312 },
7313 code: None,
7314 details: None,
7315 })
7316 }
7317 Err(error) => match error {
7318 ureq::Error::Status(412, _) => Ok(()),
7319 ureq::Error::Status(_, response) => {
7320 let status = response.status();
7321 let detail = response
7322 .into_string()
7323 .ok()
7324 .map(|body| body.chars().take(400).collect::<String>())
7325 .filter(|body| !body.trim().is_empty());
7326 Err(LinkError::Http {
7327 what: "v2 changed-byte upload",
7328 status,
7329 message: match detail {
7330 Some(body) => format!(
7331 "object store rejected the upload of `{}`: {}",
7332 source.path,
7333 body.replace('\n', " ")
7334 ),
7335 None => {
7336 format!("object store rejected the upload of `{}`", source.path)
7337 }
7338 },
7339 code: None,
7340 details: None,
7341 })
7342 }
7343 ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
7344 },
7345 }
7346}
7347
7348fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
7352 if body.get("operations").is_some() {
7353 return body.clone();
7354 }
7355 let mut value = body.clone();
7356 if let Some(map) = value.as_object_mut() {
7357 map.remove("staged_change");
7358 map.insert("operations".to_string(), Value::Array(operations.to_vec()));
7359 }
7360 value
7361}
7362
7363fn reserve_upload_window(
7367 cfg: &HubConfig,
7368 path: &str,
7369 body: &Value,
7370 what: &'static str,
7371) -> LinkResult<Value> {
7372 let mut attempt = 0;
7373 loop {
7374 let pause = |attempt: usize| {
7375 std::thread::sleep(std::time::Duration::from_millis(
7376 RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
7377 ));
7378 };
7379 match request(cfg, "POST", path, Some(body), Auth::Required) {
7380 Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
7385 pause(attempt);
7386 attempt += 1;
7387 }
7388 Err(error) => return Err(error),
7389 Ok(response) => {
7390 if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
7391 pause(attempt);
7392 attempt += 1;
7393 continue;
7394 }
7395 return ensure_ok(response, what);
7396 }
7397 }
7398 }
7399}
7400
7401fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
7405 let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
7406 .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
7407 if bytes.len() > MAX_STAGED_CHANGE_BYTES {
7408 return Err(LinkError::PushTooLarge {
7409 detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
7410 });
7411 }
7412 Ok(bytes)
7413}
7414
7415fn stage_v2_change(
7425 cfg: &HubConfig,
7426 requested_brain: &str,
7427 operations: &[Value],
7428 blobs: Value,
7429) -> LinkResult<Value> {
7430 let bytes = v2_change_manifest(operations, blobs)?;
7431 let sha256 = content_sha256(&bytes);
7432 let reserved = reserve_upload_window(
7433 cfg,
7434 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7435 &json!({
7436 "blobs": [{
7437 "sha256": sha256,
7438 "bytes": bytes.len(),
7439 "kind": "staged_change",
7440 }],
7441 }),
7442 "stage the v2 change",
7443 )?;
7444 let items = reserved
7445 .get("uploads")
7446 .and_then(Value::as_array)
7447 .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
7448 let [item] = items.as_slice() else {
7449 return Err(invalid_feed(
7450 "v2 change staging response changed the requested set",
7451 ));
7452 };
7453 let reservation_id = item
7454 .get("reservation_id")
7455 .and_then(Value::as_str)
7456 .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
7457 if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
7458 || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
7459 || !crate::ulid::is_ulid(reservation_id)
7460 {
7461 return Err(invalid_feed("v2 change staging item is inconsistent"));
7462 }
7463 match item.get("status").and_then(Value::as_str) {
7464 Some("upload") => put_presigned(
7465 cfg,
7466 item.get("url")
7467 .and_then(Value::as_str)
7468 .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
7469 item.get("headers").unwrap_or(&Value::Null),
7470 &bytes,
7471 )?,
7472 Some("already_present") => {}
7473 _ => return Err(invalid_feed("v2 change staging has an unknown status")),
7474 }
7475 Ok(json!({
7476 "sha256": sha256,
7477 "bytes": bytes.len(),
7478 "reservation_id": reservation_id,
7479 }))
7480}
7481
7482fn stage_oversized_v2_change(
7486 cfg: &HubConfig,
7487 requested_brain: &str,
7488 operations: &[Value],
7489 body: &mut Value,
7490) -> LinkResult<()> {
7491 if body.to_string().len() <= MAX_PUSH_BYTES {
7492 return Ok(());
7493 }
7494 let staged = stage_v2_change(
7495 cfg,
7496 requested_brain,
7497 operations,
7498 body.get("blobs")
7499 .cloned()
7500 .unwrap_or(Value::Array(Vec::new())),
7501 )?;
7502 let map = body
7503 .as_object_mut()
7504 .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
7505 map.remove("operations");
7506 map.remove("blobs");
7507 map.insert("staged_change".to_string(), staged);
7508 Ok(())
7509}
7510
7511fn v2_sync_push(
7512 cfg: &HubConfig,
7513 requested_brain: &str,
7514 store: &Store,
7515 head: V2VerifiedHead,
7516 options: V2SyncPushOptions<'_>,
7517) -> LinkResult<Value> {
7518 let V2SyncPushOptions {
7519 resume_local_policy,
7520 bulk_confirmation,
7521 resolution,
7522 pulled,
7523 withdrawal_paths,
7524 withdrawal_reason,
7525 } = options;
7526 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
7527 let head = v2_verified_head(cfg, requested_brain)?
7528 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7529 let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
7530 match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
7531 Some(snapshot) => (
7532 snapshot.files,
7533 snapshot.assets,
7534 Some(snapshot.local),
7535 Some(snapshot.local_assets),
7536 ),
7537 None => (
7538 files_for_v2_view(
7539 &head,
7540 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7541 ),
7542 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7543 None,
7544 None,
7545 ),
7546 };
7547 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
7548 ensure_v2_view_compatible(&head, baseline.as_ref())?;
7549 if head.view_kind == "scoped" && baseline.is_none() {
7550 return Err(LinkError::ScopedViewChanged);
7551 }
7552 let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
7553 let local = &local_view.riding;
7554 let local_assets = match carried_local_assets {
7555 Some(assets) => assets,
7556 None => v2_local_asset_records(store)?,
7557 };
7558 if withdrawal_paths.len() > MAX_PUSH_FILES {
7559 return Err(LinkError::PushTooLarge {
7560 detail: "too many explicit withdrawal paths".to_string(),
7561 });
7562 }
7563 let withdrawal_reason = if withdrawal_paths.is_empty() {
7564 None
7565 } else {
7566 let reason = withdrawal_reason
7567 .map(str::trim)
7568 .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
7569 .ok_or_else(|| LinkError::InvalidPack {
7570 message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
7571 })?;
7572 Some(reason)
7573 };
7574 let mut withdrawals = withdrawal_paths
7575 .iter()
7576 .map(|path| {
7577 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
7578 path: error.to_string(),
7579 })
7580 })
7581 .collect::<LinkResult<Vec<_>>>()?;
7582 withdrawals.sort();
7583 withdrawals.dedup();
7584 if withdrawals.len() != withdrawal_paths.len() {
7585 return Err(LinkError::InvalidPack {
7586 message: "explicit withdrawal paths must be unique".to_string(),
7587 });
7588 }
7589 let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
7590 let mut consumed_withdrawals = BTreeSet::new();
7591 if let Some(previous) = baseline.as_ref() {
7592 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
7593 && !resume_local_policy
7594 {
7595 let mut newly_eligible = previous
7596 .local_eligibility
7597 .iter()
7598 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7599 .map(|(path, _)| path.clone())
7600 .collect::<Vec<_>>();
7601 if !newly_eligible.is_empty() {
7602 newly_eligible.truncate(100);
7603 return Err(LinkError::LocalPolicyTransition {
7604 paths: newly_eligible,
7605 });
7606 }
7607 }
7608 }
7609 let base = match baseline.as_ref() {
7610 Some(state) => &state.files,
7611 None if remote.is_empty() => &remote,
7612 None => {
7613 let mut conflicts = remote
7614 .iter()
7615 .filter(|(path, file)| {
7616 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7617 })
7618 .map(|(path, _)| path.clone())
7619 .collect::<Vec<_>>();
7620 if !conflicts.is_empty() {
7621 conflicts.truncate(100);
7622 let (bundle, paths) =
7623 create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7624 return Err(LinkError::ConflictBundle { bundle, paths });
7625 }
7626 &remote
7627 }
7628 };
7629 let all_paths = base
7630 .keys()
7631 .chain(remote.keys())
7632 .chain(local.keys())
7633 .cloned()
7634 .collect::<std::collections::BTreeSet<_>>();
7635 let mut conflicts = Vec::new();
7636 let mut operations = Vec::new();
7637 let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7638 for path in all_paths {
7639 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7640 let remote_file = remote.get(&path);
7641 let remote_hash = remote_file.map(|file| file.sha256.as_str());
7642 let local_file = local.get(&path);
7643 let local_hash = local_file.map(|file| file.0.as_str());
7644 if local_hash == base_hash {
7645 continue;
7646 }
7647 if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
7648 continue;
7649 }
7650 if local_view.policy.keeps_home(&path) {
7651 continue;
7654 }
7655 if remote_hash != base_hash && local_hash != remote_hash {
7656 let explicitly_resolved = resolution
7657 .and_then(|allowed| allowed.get(&path))
7658 .is_some_and(|selected| {
7659 selected.expected_remote.as_deref() == remote_hash
7660 && selected.selected_local.as_deref() == local_hash
7661 });
7662 if !explicitly_resolved {
7663 conflicts.push(path);
7664 continue;
7665 }
7666 }
7667 match local_file {
7668 Some((sha256, byte_count)) => {
7669 verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7670 operations.push(json!({
7671 "op": "put",
7672 "path": path,
7673 "expected": v2_expected(remote_file),
7674 "blob": sha256,
7675 "bytes": byte_count,
7676 }));
7677 upload_sources
7678 .entry(sha256.clone())
7679 .or_insert_with(|| V2UploadSource {
7680 path: path.clone(),
7681 bytes: *byte_count,
7682 });
7683 }
7684 None => {
7685 let Some(current) = remote_file else {
7686 continue;
7687 };
7688 operations.push(json!({
7689 "op": "delete",
7690 "path": path,
7691 "expected": { "kind": "blob", "hash": current.sha256 },
7692 }));
7693 }
7694 }
7695 }
7696 operations = infer_exact_source_promotions(operations);
7697 for path in &withdrawals {
7698 if local_assets.contains_key(path) {
7699 continue;
7700 }
7701 operations.push(v2_content_withdrawal_operation(
7702 store,
7703 &local_view,
7704 &remote,
7705 path,
7706 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7707 )?);
7708 consumed_withdrawals.insert(path.clone());
7709 }
7710 if !conflicts.is_empty() {
7711 conflicts.truncate(100);
7712 let (bundle, paths) = create_v2_conflict_bundle(
7713 cfg,
7714 store,
7715 &head,
7716 baseline.as_ref(),
7717 local,
7718 &remote,
7719 &conflicts,
7720 )?;
7721 return Err(LinkError::ConflictBundle { bundle, paths });
7722 }
7723 let base_assets = match baseline.as_ref() {
7724 Some(state) => &state.assets,
7725 None if remote_assets.is_empty() => &remote_assets,
7726 None => {
7727 let mismatched = remote_assets.iter().any(|(path, remote)| {
7728 local_assets.get(path) != Some(&v2_asset_record(remote, path))
7729 }) || local_assets.len() != remote_assets.len();
7730 if mismatched {
7731 return Err(LinkError::Conflict {
7732 paths: vec!["assets.jsonl".to_string()],
7733 });
7734 }
7735 &remote_assets
7736 }
7737 };
7738 let asset_paths = base_assets
7739 .keys()
7740 .chain(remote_assets.keys())
7741 .chain(local_assets.keys())
7742 .cloned()
7743 .collect::<std::collections::BTreeSet<_>>();
7744 let mut asset_policy_transitions = Vec::new();
7745 for path in asset_paths {
7746 let base_record = base_assets
7747 .get(&path)
7748 .map(|asset| v2_asset_record(asset, &path));
7749 let remote = remote_assets.get(&path);
7750 let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
7751 let local_record = local_assets.get(&path);
7752 if withdrawal_set.contains(&path) {
7753 let record = local_record.ok_or_else(|| LinkError::InvalidPack {
7754 message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
7755 })?;
7756 let current = remote.ok_or_else(|| LinkError::InvalidPack {
7757 message: format!(
7758 "asset withdrawal path `{path}` has no readable hosted coordinate"
7759 ),
7760 })?;
7761 operations.push(v2_asset_withdrawal_operation(
7762 store,
7763 &local_view,
7764 &path,
7765 record,
7766 current,
7767 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7768 )?);
7769 consumed_withdrawals.insert(path.clone());
7770 continue;
7771 }
7772 let mut raw_present = false;
7773 let mut disposition = "withheld";
7774 let mut resumes_hosting = false;
7775 if let Some(record) = local_record {
7776 crate::linkmd_v2::normalize_path(&record.path)
7777 .map_err(|error| invalid_feed(error.to_string()))?;
7778 let kept_home = local_view.policy.keeps_home(&path);
7779 raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
7780 disposition = if kept_home || !raw_present {
7781 "withheld"
7782 } else {
7783 "hosted"
7784 };
7785 if !raw_present && record.required && !kept_home {
7786 return Err(LinkError::InvalidPack {
7787 message: format!("required asset {path} is missing"),
7788 });
7789 }
7790 resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
7791 }
7792 if local_record == base_record.as_ref() && !resumes_hosting {
7793 continue;
7794 }
7795 if remote_record != base_record && local_record != remote_record.as_ref() {
7796 conflicts.push(path);
7797 continue;
7798 }
7799 let Some(record) = local_record else {
7800 if let Some(remote) = remote {
7801 operations.push(json!({
7802 "op": "asset_delete",
7803 "path": path,
7804 "expected": v2_asset_expected(Some(remote)),
7805 }));
7806 }
7807 continue;
7808 };
7809 let raw = if raw_present {
7810 verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
7811 Some(())
7812 } else {
7813 None
7814 };
7815 let op = if resumes_hosting {
7816 if !resume_local_policy {
7817 asset_policy_transitions.push(path);
7818 continue;
7819 }
7820 "asset_resume"
7821 } else {
7822 "asset_put"
7823 };
7824 operations.push(json!({
7825 "op": op,
7826 "path": path,
7827 "expected": v2_asset_expected(remote),
7828 "asset": v2_asset_value(record, disposition),
7829 }));
7830 if disposition == "hosted" {
7831 raw.expect("hosted asset was checked present");
7832 upload_sources
7833 .entry(record.sha256.clone())
7834 .or_insert_with(|| V2UploadSource {
7835 path: path.clone(),
7836 bytes: record.bytes,
7837 });
7838 }
7839 }
7840 if consumed_withdrawals != withdrawal_set {
7841 let missing = withdrawal_set
7842 .difference(&consumed_withdrawals)
7843 .next()
7844 .expect("different withdrawal sets have one member");
7845 return Err(LinkError::InvalidPack {
7846 message: format!(
7847 "withdrawal path `{missing}` is not a readable content or asset coordinate"
7848 ),
7849 });
7850 }
7851 if !conflicts.is_empty() {
7852 conflicts.truncate(100);
7853 return Err(LinkError::Conflict { paths: conflicts });
7854 }
7855 if !asset_policy_transitions.is_empty() {
7856 asset_policy_transitions.truncate(100);
7857 return Err(LinkError::LocalPolicyTransition {
7858 paths: asset_policy_transitions,
7859 });
7860 }
7861 let touched_sources = operations
7862 .iter()
7863 .filter_map(
7864 |operation| match operation.get("op").and_then(Value::as_str) {
7865 Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
7866 Some("rename") => operation.get("to").and_then(Value::as_str),
7867 _ => None,
7868 },
7869 )
7870 .collect::<std::collections::BTreeSet<_>>();
7871 let withheld_links = local_view
7872 .withheld_links
7873 .iter()
7874 .filter(|link| touched_sources.contains(link.source.as_str()))
7875 .collect::<Vec<_>>();
7876 let checkout_pseudonym = v2_checkout_id(
7877 baseline
7878 .as_ref()
7879 .and_then(|current| current.checkout_id.as_deref()),
7880 )?;
7881 let checkout_id = if withheld_links.is_empty() {
7882 None
7883 } else {
7884 Some(checkout_pseudonym.clone())
7885 };
7886 if operations.is_empty() {
7887 let final_head = v2_verified_head(cfg, requested_brain)?
7888 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
7889 if !same_v2_head(&head, &final_head) {
7890 return Err(LinkError::RemoteAdvancedDuringSync);
7891 }
7892 let mut final_local = v2_local_files(store)?;
7893 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
7894 let final_assets = v2_local_asset_records(store)?;
7895 let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
7896 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
7897 final_local.policy.keeps_home(path)
7898 }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
7899 let next = v2_baseline_from_head(
7900 cfg,
7901 &head,
7902 remote,
7903 remote_assets,
7904 Some(&final_local),
7905 Some(&checkout_pseudonym),
7906 )?;
7907 let split_count = next.remote_copy_remains.len();
7908 accept_v2_head(cfg, &final_head)?;
7909 if !local_changed && !remote_ahead {
7910 refresh_scoped_view_marker(store, &head, next.files.len())?;
7911 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
7912 }
7913 return Ok(json!({
7914 "v": 2,
7915 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
7916 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
7917 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
7918 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
7919 "local_policy": {
7920 "remote_copy_remains": split_count,
7921 },
7922 }));
7923 }
7924 let includes_contract = operations
7925 .iter()
7926 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
7927 let rebase = if head.pointer.is_none() || includes_contract {
7928 "strict"
7929 } else {
7930 "disjoint"
7931 };
7932 let base_value = head.pointer.as_ref().map(|pointer| {
7933 json!({
7934 "seq": pointer.seq,
7935 "commit_hash": pointer.commit_hash,
7936 "content_root": pointer.content_root,
7937 "asset_root": pointer.asset_root,
7938 })
7939 });
7940 let entropy = format!(
7944 "{}\0{}\0{}\0{}\0{}\0{}",
7945 normalized_origin(&cfg.hub)?,
7946 head.brain_id,
7947 serde_json::to_string(&base_value).unwrap_or_default(),
7948 serde_json::to_string(&operations).unwrap_or_default(),
7949 serde_json::to_string(&withheld_links).unwrap_or_default(),
7950 checkout_id.as_deref().unwrap_or("")
7951 );
7952 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
7953 let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
7954 total
7955 .checked_add(source.bytes)
7956 .ok_or_else(|| LinkError::PushTooLarge {
7957 detail: "v2 changed-byte total overflow".to_string(),
7958 })
7959 })?;
7960 let inline = changed_bytes <= 3 * 1024 * 1024;
7961 let inline_blobs = if inline {
7962 upload_sources
7963 .iter()
7964 .map(|(sha256, source)| {
7965 let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
7966 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
7967 return Err(LinkError::InvalidPack {
7968 message: format!("local path `{}` changed before upload", source.path),
7969 });
7970 }
7971 Ok(json!({
7972 "sha256": sha256,
7973 "bytes": source.bytes,
7974 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
7975 }))
7976 })
7977 .collect::<LinkResult<Vec<_>>>()?
7978 } else {
7979 Vec::new()
7980 };
7981 let mut body = json!({
7982 "mutation_id": mutation_id,
7983 "base": base_value,
7984 "rebase": rebase,
7985 "reason": "dbmd sync",
7986 "operations": operations,
7987 "blobs": inline_blobs,
7988 });
7989 if !withheld_links.is_empty() {
7990 body["withheld_links"] = serde_json::to_value(&withheld_links)
7991 .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
7992 body["checkout_id"] =
7993 Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
7994 }
7995 if let Some(confirmation) = bulk_confirmation {
7996 if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
7997 return Err(LinkError::InvalidPack {
7998 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
7999 .to_string(),
8000 });
8001 }
8002 body["rebase"] = Value::String("strict".to_string());
8006 body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8007 body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8008 }
8009 if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8010 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8011 for operation in &operations {
8012 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8013 return Err(invalid_feed("v2 upload operation has no kind"));
8014 };
8015 let hash = match kind {
8016 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
8017 "asset_put" | "asset_resume" => operation
8018 .get("asset")
8019 .and_then(|asset| asset.get("blob_sha256"))
8020 .and_then(Value::as_str),
8021 _ => None,
8022 };
8023 let Some(hash) = hash else { continue };
8024 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8025 if kind == "rename" {
8026 for field in ["from", "to"] {
8027 coordinates.insert(
8028 operation
8029 .get(field)
8030 .and_then(Value::as_str)
8031 .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8032 .to_string(),
8033 );
8034 }
8035 } else {
8036 let path = operation
8037 .get("path")
8038 .and_then(Value::as_str)
8039 .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8040 coordinates.insert(if kind.starts_with("asset_") {
8041 format!("assets/{path}")
8042 } else {
8043 path.to_string()
8044 });
8045 }
8046 }
8047 let declarations = upload_sources
8048 .iter()
8049 .map(|(sha256, source)| {
8050 json!({
8051 "sha256": sha256,
8052 "bytes": source.bytes,
8053 "coordinates": coordinates_by_hash
8054 .get(sha256)
8055 .into_iter()
8056 .flatten()
8057 .collect::<Vec<_>>(),
8058 })
8059 })
8060 .collect::<Vec<_>>();
8061 let mut references = Vec::with_capacity(upload_sources.len());
8062 let mut seen = std::collections::BTreeSet::new();
8063 let mut reserved_count = 0usize;
8064 let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8065 for batch in batch_upload_declarations(declarations) {
8069 let batch_len = batch.len();
8070 let reserved = reserve_upload_window(
8071 cfg,
8072 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8073 &json!({ "blobs": batch }),
8074 "prepare v2 changed-byte uploads",
8075 )?;
8076 let items = reserved
8077 .get("uploads")
8078 .and_then(Value::as_array)
8079 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8080 if items.len() != batch_len {
8081 return Err(invalid_feed(
8082 "v2 upload reservation response changed the requested set",
8083 ));
8084 }
8085 reserved_count += items.len();
8086 for item in items {
8087 let sha256 = item
8088 .get("sha256")
8089 .and_then(Value::as_str)
8090 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8091 let source = upload_sources
8092 .get(sha256)
8093 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8094 let declared_bytes = item
8095 .get("bytes")
8096 .and_then(Value::as_u64)
8097 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8098 let reservation_id = item
8099 .get("reservation_id")
8100 .and_then(Value::as_str)
8101 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8102 let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8103 invalid_feed("v2 upload reservation has no coordinate binding")
8104 })?;
8105 let returned_coordinates = item
8106 .get("coordinates")
8107 .and_then(Value::as_array)
8108 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8109 if declared_bytes != source.bytes
8110 || !crate::ulid::is_ulid(reservation_id)
8111 || !seen.insert(sha256.to_string())
8112 || returned_coordinates.len() != expected_coordinates.len()
8113 || returned_coordinates
8114 .iter()
8115 .zip(expected_coordinates)
8116 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8117 {
8118 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8119 }
8120 match item.get("status").and_then(Value::as_str) {
8121 Some("upload") => {
8122 let url = item
8123 .get("url")
8124 .and_then(Value::as_str)
8125 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8126 pending_uploads.push(V2PendingUpload {
8127 url: url.to_string(),
8128 headers: item.get("headers").cloned().unwrap_or(Value::Null),
8129 sha256: sha256.to_string(),
8130 source,
8131 });
8132 }
8133 Some("already_present") => {}
8134 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
8135 }
8136 references.push(json!({
8137 "sha256": sha256,
8138 "bytes": source.bytes,
8139 "reservation_id": reservation_id,
8140 }));
8141 }
8142 upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
8148 pending_uploads.clear();
8149 }
8150 if reserved_count != upload_sources.len() {
8151 return Err(invalid_feed(
8152 "v2 upload reservation response changed the requested set",
8153 ));
8154 }
8155 body["blobs"] = Value::Array(references);
8156 }
8157 stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
8158 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
8159 let mut candidate_hub_signer: Option<String> = None;
8160 let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8161 let bulk_preview_required = !(200..300).contains(&response.status)
8162 && response.body.as_ref().is_some_and(|value| {
8163 value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
8164 || value
8165 .get("details")
8166 .and_then(|details| details.get("code"))
8167 .and_then(Value::as_str)
8168 == Some("bulk_preview_required")
8169 });
8170 if bulk_preview_required && bulk_confirmation.is_none() {
8171 body["rebase"] = Value::String("strict".to_string());
8172 body["preview_only"] = Value::Bool(true);
8173 let preview = ensure_ok(
8174 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
8175 "v2 bulk preview",
8176 )?;
8177 let preview_code = preview.get("code").and_then(Value::as_str);
8178 let required = preview.get("required").and_then(Value::as_bool);
8179 if preview.get("v").and_then(Value::as_u64) != Some(2)
8180 || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
8181 || !matches!(
8182 preview_code,
8183 Some("bulk_preview_created" | "bulk_preview_not_required")
8184 )
8185 || required.is_none()
8186 {
8187 return Err(invalid_feed(
8188 "bulk preview response is not bound to the requested mutation",
8189 ));
8190 }
8191 if required == Some(true) {
8192 let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
8193 let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
8194 if preview_code != Some("bulk_preview_created")
8195 || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
8196 || preview_digest.is_none_or(|value| !is_sha256(value))
8197 || preview.get("expires_at").and_then(Value::as_str).is_none()
8198 || !preview.get("impact").is_some_and(Value::is_object)
8199 {
8200 return Err(invalid_feed("bulk preview receipt is malformed"));
8201 }
8202 return Err(LinkError::BulkPreviewRequired { preview });
8203 }
8204 if preview_code != Some("bulk_preview_not_required") {
8205 return Err(invalid_feed("bulk preview requirement is inconsistent"));
8206 }
8207 body.as_object_mut()
8210 .expect("v2 commit request is an object")
8211 .remove("preview_only");
8212 response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8213 }
8214 let mut result = ensure_ok(response, "v2 sync push")?;
8215 if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
8216 if let Some(object) = result.as_object_mut() {
8217 object.insert(
8218 "sync_status".to_string(),
8219 Value::String("proposal_pending".to_string()),
8220 );
8221 }
8222 return Ok(result);
8223 }
8224 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
8225 let request_id = result
8226 .get("request_id")
8227 .and_then(Value::as_str)
8228 .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
8229 .to_string();
8230 let challenge = result
8231 .get("signing_challenge")
8232 .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
8233 let mut expected_candidate = remote.clone();
8234 let mut expected_candidate_assets = remote_assets.clone();
8235 apply_generated_v2_operations(
8236 &operations,
8237 &local_assets,
8238 &mut expected_candidate,
8239 &mut expected_candidate_assets,
8240 )?;
8241 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
8242 cfg,
8243 &head,
8244 &expected_candidate,
8245 &expected_candidate_assets,
8246 &mutation_id,
8247 &v2_signed_request_view(&body, &operations),
8248 challenge,
8249 )?;
8250 body["signing_challenge_id"] = Value::String(challenge_id);
8251 body["signature_base64url"] = Value::String(signature);
8252 candidate_hub_signer = Some(actor_signer);
8253 result = ensure_ok(
8254 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
8255 "v2 self-custody commit",
8256 )?;
8257 }
8258 let refreshed = v2_verified_head(cfg, requested_brain)?
8259 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
8260 if candidate_hub_signer
8261 .as_ref()
8262 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
8263 {
8264 return Err(invalid_feed(
8265 "self-custody actor signer differs from the committed hub pointer signer",
8266 ));
8267 }
8268 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
8269 if refreshed
8270 .pointer
8271 .as_ref()
8272 .map(|pointer| pointer.commit_hash.as_str())
8273 != accepted_hash
8274 {
8275 return Err(LinkError::RemoteAdvancedDuringSync);
8276 }
8277 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
8278 let rebased = result
8279 .get("rebased")
8280 .and_then(Value::as_bool)
8281 .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
8282 let (refreshed_files, refreshed_assets) = if rebased {
8283 (
8284 files_for_v2_view(
8285 &refreshed,
8286 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8287 ),
8288 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8289 )
8290 } else {
8291 let asset_changed = apply_generated_v2_operations(
8292 &operations,
8293 &local_assets,
8294 &mut remote,
8295 &mut remote_assets,
8296 )?;
8297 let assets = if asset_changed {
8298 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
8301 } else {
8302 remote_assets
8303 };
8304 (remote, assets)
8305 };
8306 let mut final_local = v2_local_files(store)?;
8307 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
8308 let final_assets = v2_local_asset_records(store)?;
8309 let local_dirty = final_local.riding != local_view.riding
8310 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
8311 final_local.policy.keeps_home(path)
8312 })
8313 || final_assets != local_assets
8314 || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
8315 let next = v2_baseline_from_head(
8316 cfg,
8317 &refreshed,
8318 refreshed_files,
8319 refreshed_assets,
8320 Some(&final_local),
8321 Some(&checkout_pseudonym),
8322 )?;
8323 let split_count = next.remote_copy_remains.len();
8324 accept_v2_head(cfg, &refreshed)?;
8325 if !local_dirty {
8326 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
8327 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
8328 }
8329 if let Some(object) = result.as_object_mut() {
8330 object.insert(
8331 "local_policy".to_string(),
8332 json!({ "remote_copy_remains": split_count }),
8333 );
8334 object.insert(
8335 "sync_status".to_string(),
8336 Value::String(if local_dirty {
8337 "remote_committed_local_dirty".to_string()
8338 } else {
8339 "synced".to_string()
8340 }),
8341 );
8342 }
8343 Ok(result)
8344}
8345
8346pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
8349 sync_push_incremental_with_policy(cfg, brain, store, false)
8350}
8351
8352pub fn sync_push_incremental_with_policy(
8355 cfg: &HubConfig,
8356 brain: &str,
8357 store: &Store,
8358 resume_local_policy: bool,
8359) -> LinkResult<Value> {
8360 sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
8361}
8362
8363pub fn sync_push_incremental_with_options(
8366 cfg: &HubConfig,
8367 brain: &str,
8368 store: &Store,
8369 resume_local_policy: bool,
8370 bulk_confirmation: Option<&V2BulkConfirmation>,
8371) -> LinkResult<Value> {
8372 sync_push_incremental_with_controls(
8373 cfg,
8374 brain,
8375 store,
8376 resume_local_policy,
8377 bulk_confirmation,
8378 &[],
8379 None,
8380 )
8381}
8382
8383pub fn sync_push_incremental_with_controls(
8385 cfg: &HubConfig,
8386 brain: &str,
8387 store: &Store,
8388 resume_local_policy: bool,
8389 bulk_confirmation: Option<&V2BulkConfirmation>,
8390 withdrawal_paths: &[String],
8391 withdrawal_reason: Option<&str>,
8392) -> LinkResult<Value> {
8393 require_safe_ref(brain)?;
8394 if let Some(head) = v2_verified_head(cfg, brain)? {
8395 return v2_sync_push(
8396 cfg,
8397 brain,
8398 store,
8399 head,
8400 V2SyncPushOptions {
8401 resume_local_policy,
8402 bulk_confirmation,
8403 resolution: None,
8404 pulled: None,
8405 withdrawal_paths,
8406 withdrawal_reason,
8407 },
8408 );
8409 }
8410 if !withdrawal_paths.is_empty() {
8411 return Err(LinkError::InvalidPack {
8412 message: "explicit withdrawal requires a link.md v2 brain".to_string(),
8413 });
8414 }
8415 legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
8416}
8417
8418pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
8422 require_safe_ref(brain)?;
8423 Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
8424}
8425
8426#[cfg(windows)]
8427fn legacy_sync_push_incremental(
8428 _cfg: &HubConfig,
8429 _brain: &str,
8430 _store: &Store,
8431 _resume_local_policy: bool,
8432 _bulk_confirmation: Option<&V2BulkConfirmation>,
8433) -> LinkResult<Value> {
8434 Err(LinkError::UnsupportedPlatform {
8435 operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
8436 })
8437}
8438
8439#[cfg(not(windows))]
8440fn legacy_sync_push_incremental(
8441 cfg: &HubConfig,
8442 brain: &str,
8443 store: &Store,
8444 resume_local_policy: bool,
8445 bulk_confirmation: Option<&V2BulkConfirmation>,
8446) -> LinkResult<Value> {
8447 if resume_local_policy || bulk_confirmation.is_some() {
8448 return Err(LinkError::InvalidPack {
8449 message: "v2 sync options require a link.md v2 brain".to_string(),
8450 });
8451 }
8452 let files = collect_push_files(store)?;
8453 sync_push(cfg, brain, &files)
8454}
8455
8456#[derive(Debug, Clone)]
8458pub enum V2ConflictChoice {
8459 KeepLocal,
8460 TakeRemote,
8461 From(PathBuf),
8462}
8463
8464fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
8465 if !crate::ulid::is_ulid(bundle) {
8466 return Err(LinkError::InvalidPack {
8467 message: "conflict bundle must be a lowercase ULID".to_string(),
8468 });
8469 }
8470 let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
8471 let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
8472 .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
8473 if plan.v != 2
8474 || plan.class != "content_resolution_required"
8475 || plan.bundle != bundle
8476 || !crate::ulid::is_ulid(&plan.brain)
8477 || plan.files.is_empty()
8478 || plan.files.len() > 100
8479 || plan.files.iter().any(|file| {
8480 crate::linkmd_v2::normalize_path(&file.path).is_err()
8481 || [&file.base, &file.local, &file.remote]
8482 .into_iter()
8483 .any(|coordinate| {
8484 coordinate
8485 .sha256
8486 .as_deref()
8487 .is_some_and(|hash| !is_sha256(hash))
8488 || coordinate.file.as_deref().is_some_and(|name| {
8489 name.starts_with('/')
8490 || name
8491 .split('/')
8492 .any(|part| part.is_empty() || part == "." || part == "..")
8493 })
8494 })
8495 })
8496 {
8497 return Err(invalid_feed("private conflict plan failed validation"));
8498 }
8499 Ok(plan)
8500}
8501
8502pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
8507 require_hardened_filesystem("private conflict maintenance")?;
8508 if all && !prune {
8509 return Err(LinkError::InvalidPack {
8510 message: "discarding all conflict bundles requires prune=true".to_string(),
8511 });
8512 }
8513 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8514 message: format!("conflict checkout is not a valid db.md store: {error}"),
8515 })?;
8516 let _transaction = store.transaction()?;
8517 let root = Path::new(".dbmd/conflicts");
8518 let names = match store.directory_names(root) {
8519 Ok(names) => names,
8520 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8521 Err(error) => return Err(error.into()),
8522 };
8523 let now = SystemTime::now()
8524 .duration_since(UNIX_EPOCH)
8525 .unwrap_or_default()
8526 .as_secs();
8527 let mut bundles = Vec::new();
8528 let mut pruned = 0_u64;
8529 for name in names {
8530 let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
8531 continue;
8532 };
8533 let plan_path = v2_conflict_relative(bundle, "plan.json");
8534 let plan_exists = store.regular_file_exists(&plan_path)?;
8535 let expired = if plan_exists {
8536 match load_v2_conflict_plan(&store, bundle) {
8537 Ok(plan) => plan.expires_unix < now,
8538 Err(error) if all => {
8539 let _ = error;
8540 true
8541 }
8542 Err(error) => return Err(error),
8543 }
8544 } else {
8545 true
8546 };
8547 if prune && (all || expired) {
8548 store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8549 pruned += 1;
8550 continue;
8551 }
8552 bundles.push(json!({
8553 "bundle": bundle,
8554 "complete": plan_exists,
8555 "expired": expired,
8556 }));
8557 }
8558 Ok(json!({
8559 "v": 2,
8560 "class": "private_conflict_state",
8561 "bundles": bundles.len(),
8562 "pruned": pruned,
8563 "items": bundles,
8564 }))
8565}
8566
8567pub fn sync_resolve_conflict(
8571 cfg: &HubConfig,
8572 checkout: &Path,
8573 bundle: &str,
8574 choice: V2ConflictChoice,
8575 bulk_confirmation: Option<&V2BulkConfirmation>,
8576) -> LinkResult<Value> {
8577 require_hardened_filesystem("conflict resolution")?;
8578 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8579 message: format!("conflict checkout is not a valid db.md store: {error}"),
8580 })?;
8581 let plan = load_v2_conflict_plan(&store, bundle)?;
8582 if plan.origin != normalized_origin(&cfg.hub)? {
8583 return Err(invalid_feed(
8584 "conflict bundle belongs to another hub origin",
8585 ));
8586 }
8587 let now = SystemTime::now()
8588 .duration_since(UNIX_EPOCH)
8589 .unwrap_or_default()
8590 .as_secs();
8591 if now > plan.expires_unix {
8592 return Err(LinkError::InvalidPack {
8593 message: "conflict bundle expired; rerun sync to obtain current coordinates"
8594 .to_string(),
8595 });
8596 }
8597 let head = v2_verified_head(cfg, &plan.brain)?
8598 .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8599 let pointer = head.pointer.as_ref();
8600 if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8601 || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8602 || pointer.and_then(|value| value.content_root.as_deref())
8603 != plan.remote_content_root.as_deref()
8604 || head.view_kind != plan.view_kind
8605 || head.view_revision != plan.view_revision
8606 {
8607 return Err(LinkError::RemoteAdvancedDuringSync);
8608 }
8609
8610 for file in &plan.files {
8612 let actual = match store.regular_file_exists(Path::new(&file.path))? {
8613 true => Some(content_sha256(&store.read_bounded(
8614 Path::new(&file.path),
8615 file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8616 )?)),
8617 false => None,
8618 };
8619 if actual.as_deref() != file.local.sha256.as_deref() {
8620 return Err(LinkError::InvalidPack {
8621 message: format!(
8622 "local conflict path `{}` changed after the bundle was created",
8623 file.path
8624 ),
8625 });
8626 }
8627 }
8628
8629 let from_source = match &choice {
8630 V2ConflictChoice::From(source) => Some(source.clone()),
8631 _ => None,
8632 };
8633 let result = match choice {
8634 V2ConflictChoice::TakeRemote => {
8635 if bulk_confirmation.is_some() {
8636 return Err(LinkError::InvalidPack {
8637 message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8638 });
8639 }
8640 let current_remote =
8644 files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8645 let _ = v2_take_remote_selection(&plan.files, ¤t_remote)?;
8646 let selected = plan
8647 .files
8648 .iter()
8649 .map(|file| file.path.clone())
8650 .collect::<std::collections::BTreeSet<_>>();
8651 serde_json::to_value(
8652 v2_sync_pull_with_resolution(
8653 cfg,
8654 &plan.brain,
8655 head,
8656 Some(checkout),
8657 Some(&selected),
8658 )?
8659 .report,
8660 )
8661 .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
8662 }
8663 V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
8664 if let Some(source) = from_source.as_ref() {
8665 if plan.files.len() != 1 {
8666 return Err(LinkError::InvalidPack {
8667 message: "--from requires a bundle with exactly one conflict".to_string(),
8668 });
8669 }
8670 let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
8671 if std::str::from_utf8(&candidate).is_err() {
8672 return Err(LinkError::NotUtf8 {
8673 path: source.display().to_string(),
8674 });
8675 }
8676 store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
8677 }
8678 let refreshed_store =
8679 Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8680 message: format!("resolved checkout is not a valid db.md store: {error}"),
8681 })?;
8682 let mut overrides = std::collections::BTreeMap::new();
8683 for file in &plan.files {
8684 let selected_local = match refreshed_store
8685 .regular_file_exists(Path::new(&file.path))?
8686 {
8687 true => Some(content_sha256(
8688 &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
8689 )),
8690 false => None,
8691 };
8692 overrides.insert(
8693 file.path.clone(),
8694 V2ResolutionOverride {
8695 expected_remote: file.remote.sha256.clone(),
8696 selected_local,
8697 },
8698 );
8699 }
8700 v2_sync_push(
8701 cfg,
8702 &plan.brain,
8703 &refreshed_store,
8704 head,
8705 V2SyncPushOptions {
8706 resume_local_policy: true,
8707 bulk_confirmation,
8708 resolution: Some(&overrides),
8709 pulled: None,
8710 withdrawal_paths: &[],
8711 withdrawal_reason: None,
8712 },
8713 )?
8714 }
8715 };
8716
8717 if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
8718 let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8719 message: format!("resolved checkout is not a valid db.md store: {error}"),
8720 })?;
8721 installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8722 }
8723 Ok(json!({
8724 "v": 2,
8725 "class": "auto_converged",
8726 "bundle": bundle,
8727 "receipt": result,
8728 }))
8729}
8730
8731pub fn sync_converge(
8742 cfg: &HubConfig,
8743 brain: &str,
8744 checkout: &Path,
8745 resume_local_policy: bool,
8746) -> LinkResult<Value> {
8747 sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
8748}
8749
8750pub fn sync_converge_with_options(
8752 cfg: &HubConfig,
8753 brain: &str,
8754 checkout: &Path,
8755 resume_local_policy: bool,
8756 bulk_confirmation: Option<&V2BulkConfirmation>,
8757) -> LinkResult<Value> {
8758 sync_converge_with_controls(
8759 cfg,
8760 brain,
8761 checkout,
8762 resume_local_policy,
8763 bulk_confirmation,
8764 &[],
8765 None,
8766 )
8767}
8768
8769pub fn sync_converge_with_controls(
8771 cfg: &HubConfig,
8772 brain: &str,
8773 checkout: &Path,
8774 resume_local_policy: bool,
8775 bulk_confirmation: Option<&V2BulkConfirmation>,
8776 withdrawal_paths: &[String],
8777 withdrawal_reason: Option<&str>,
8778) -> LinkResult<Value> {
8779 require_hardened_filesystem("bidirectional sync")?;
8780 require_safe_ref(brain)?;
8781 let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
8782 message:
8783 "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
8784 .to_string(),
8785 })?;
8786 let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
8787 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8788 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
8789 })?;
8790 let _transaction = store.transaction()?;
8791 let pulled_report = pulled.report.clone();
8792 let pulled_head = pulled.head.clone();
8793 let mut result = v2_sync_push(
8794 cfg,
8795 brain,
8796 &store,
8797 pulled_head,
8798 V2SyncPushOptions {
8799 resume_local_policy,
8800 bulk_confirmation,
8801 resolution: None,
8802 pulled: Some(pulled),
8803 withdrawal_paths,
8804 withdrawal_reason,
8805 },
8806 )?;
8807 if let Some(object) = result.as_object_mut() {
8808 object.insert("pulled_files".to_string(), json!(pulled_report.files));
8809 object.insert("checkout".to_string(), Value::String(pulled_report.dest));
8810 object.insert(
8811 "mode".to_string(),
8812 Value::String("bidirectional".to_string()),
8813 );
8814 }
8815 Ok(result)
8816}
8817
8818pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
8824 require_hardened_filesystem("sync pull")?;
8825 require_safe_ref(brain)?;
8826 if let Some(head) = v2_verified_head(cfg, brain)? {
8827 return v2_sync_pull(cfg, brain, head, out);
8828 }
8829 legacy_sync_pull(cfg, brain, out)
8830}
8831
8832#[cfg(windows)]
8833fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
8834 Err(LinkError::UnsupportedPlatform {
8835 operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
8836 })
8837}
8838
8839#[cfg(not(windows))]
8840fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
8841 let remote = verified_remote_head(cfg, brain, false)?;
8842 if !remote.head.verified {
8843 return Err(invalid_feed(
8844 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
8845 ));
8846 }
8847 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
8848 let path = format!(
8849 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
8850 remote.head.seq
8851 );
8852 let body = ensure_ok(
8853 request(cfg, "GET", &path, None, Auth::Required)?,
8854 "sync pull",
8855 )?;
8856 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
8857 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
8858 {
8859 return Err(invalid_feed(
8860 "export response is not bound to the verified snapshot token",
8861 ));
8862 }
8863
8864 let remote_slug = body
8865 .get("slug")
8866 .and_then(Value::as_str)
8867 .filter(|slug| is_safe_slug(slug));
8868 let slug = remote_slug
8869 .or_else(|| is_safe_slug(brain).then_some(brain))
8870 .unwrap_or("brain")
8871 .to_string();
8872 let brain_id = body
8873 .get("brain")
8874 .and_then(Value::as_str)
8875 .unwrap_or(&remote.head.brain)
8876 .to_string();
8877 if brain_id != remote.head.brain {
8878 return Err(invalid_feed(
8879 "export response names a different brain than the verified head",
8880 ));
8881 }
8882 let head_seq = remote.head.seq;
8883 let dest: PathBuf = match out {
8884 Some(p) => p.to_path_buf(),
8885 None => PathBuf::from(&slug),
8886 };
8887 let entries = if head_seq == 0 {
8888 let files = body
8889 .get("files")
8890 .and_then(Value::as_array)
8891 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
8892 if !files.is_empty() || body.get("url").is_some() {
8893 return Err(invalid_feed(
8894 "empty signed feed cannot authorize non-empty exported content",
8895 ));
8896 }
8897 Vec::new()
8898 } else {
8899 let signed_head = remote
8900 .head_entry
8901 .as_ref()
8902 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
8903 let expected = &signed_head.entry.pack_sha256;
8904 if !is_sha256(expected) {
8905 return Err(invalid_feed(
8906 "signed head carries an invalid snapshot pack digest",
8907 ));
8908 }
8909 if let Some(url) = body.get("url").and_then(Value::as_str) {
8910 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
8911 return Err(invalid_feed(
8912 "export pack digest does not match the signed head entry",
8913 ));
8914 }
8915 let bytes = get_presigned(cfg, url)?;
8916 let actual = format!("{:x}", Sha256::digest(&bytes));
8917 if actual != *expected {
8918 return Err(LinkError::InvalidPack {
8919 message: "downloaded pack does not match the signed snapshot digest"
8920 .to_string(),
8921 });
8922 }
8923 let entries = parse_store_pack(bytes)?;
8924 if signed_head.entry.kind == "push" {
8925 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
8926 }
8927 entries
8928 } else {
8929 if signed_head.entry.kind != "push" {
8930 return Err(invalid_feed(
8931 "delta snapshots must export the exact signed pack",
8932 ));
8933 }
8934 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
8935 invalid_feed("verified snapshot export carried neither a pack nor files")
8936 })?;
8937 let mut entries = Vec::with_capacity(files.len());
8938 for file in files {
8939 let path = file
8940 .get("path")
8941 .and_then(Value::as_str)
8942 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
8943 let content = file
8944 .get("content")
8945 .and_then(Value::as_str)
8946 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
8947 entries.push((path.to_string(), content.as_bytes().to_vec()));
8948 }
8949 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
8950 entries
8951 }
8952 };
8953
8954 let mut seen = std::collections::HashSet::new();
8956 for (path, _) in &entries {
8957 if !safe_store_rel_path(path) {
8958 return Err(LinkError::UnsafePath { path: path.clone() });
8959 }
8960 if !seen.insert(path) {
8961 return Err(LinkError::InvalidPack {
8962 message: format!("duplicate path `{path}`"),
8963 });
8964 }
8965 }
8966 let pulled: std::collections::BTreeSet<&str> =
8969 entries.iter().map(|(p, _)| p.as_str()).collect();
8970 let mut extra_local = Vec::new();
8971 if let Ok(store) = Store::open(&dest) {
8972 if let Ok(walked) = store.walk() {
8973 for rel in walked {
8974 let rel_str = rel.to_string_lossy().replace('\\', "/");
8975 if !pulled.contains(rel_str.as_str()) {
8976 extra_local.push(rel_str);
8977 }
8978 }
8979 }
8980 }
8981 #[cfg(unix)]
8982 install_pulled_snapshot(&dest, &entries)?;
8983
8984 Ok(PullReport {
8985 brain: brain_id,
8986 slug,
8987 head_seq,
8988 files: entries.len(),
8989 dest: dest.to_string_lossy().into_owned(),
8990 extra_local,
8991 sync_status: "synced".to_string(),
8992 })
8993}
8994
8995#[cfg(unix)]
8996fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
8997 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
8998 path: display.to_string(),
8999 })
9000}
9001
9002#[cfg(unix)]
9003fn open_dir_at(
9004 parent: std::os::fd::RawFd,
9005 name: &std::ffi::CStr,
9006 display: &str,
9007) -> LinkResult<std::fs::File> {
9008 use std::os::fd::FromRawFd as _;
9009 let fd = unsafe {
9010 libc::openat(
9011 parent,
9012 name.as_ptr(),
9013 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9014 )
9015 };
9016 if fd < 0 {
9017 return Err(LinkError::UnsafePath {
9018 path: display.to_string(),
9019 });
9020 }
9021 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
9022}
9023
9024#[cfg(unix)]
9028fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
9029 use std::os::fd::AsRawFd as _;
9030
9031 #[cfg(target_os = "macos")]
9035 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
9036 .into_iter()
9037 .find_map(|(alias, real)| {
9038 path.strip_prefix(alias)
9039 .ok()
9040 .map(|rest| Path::new(real).join(rest))
9041 })
9042 .unwrap_or_else(|| path.to_path_buf());
9043 #[cfg(not(target_os = "macos"))]
9044 let normalized = path.to_path_buf();
9045
9046 let start = if normalized.is_absolute() {
9047 std::fs::File::open("/")?
9048 } else {
9049 std::fs::File::open(".")?
9050 };
9051 let mut directory = start;
9052 for component in normalized.components() {
9053 use std::path::Component;
9054 let name = match component {
9055 Component::RootDir | Component::CurDir => continue,
9056 Component::Normal(name) => name,
9057 Component::ParentDir | Component::Prefix(_) => {
9058 return Err(LinkError::UnsafePath {
9059 path: path.display().to_string(),
9060 });
9061 }
9062 };
9063 use std::os::unix::ffi::OsStrExt as _;
9064 let name = c_name(name.as_bytes(), &path.display().to_string())?;
9065 if create {
9066 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9067 if made != 0 {
9068 let error = std::io::Error::last_os_error();
9069 if error.raw_os_error() != Some(libc::EEXIST) {
9070 return Err(error.into());
9071 }
9072 }
9073 }
9074 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
9075 }
9076 Ok(directory)
9077}
9078
9079#[cfg(unix)]
9080fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9081 open_dir_path_nofollow(path, true)
9082}
9083
9084#[cfg(unix)]
9085fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9086 open_dir_path_nofollow(path, false)
9087}
9088
9089#[cfg(unix)]
9090fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
9091 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9092 let result =
9093 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
9094 if result == 0 {
9095 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
9096 }
9097 let error = std::io::Error::last_os_error();
9098 if error.kind() == std::io::ErrorKind::NotFound {
9099 Ok(None)
9100 } else {
9101 Err(error.into())
9102 }
9103}
9104
9105#[cfg(unix)]
9106fn create_dir_exclusive_at(
9107 parent: std::os::fd::RawFd,
9108 name: &std::ffi::CStr,
9109 display: &str,
9110) -> LinkResult<std::fs::File> {
9111 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
9112 if made != 0 {
9113 return Err(LinkError::UnsafePath {
9114 path: display.to_string(),
9115 });
9116 }
9117 open_dir_at(parent, name, display)
9118}
9119
9120#[cfg(unix)]
9121fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
9122 use std::os::fd::AsRawFd as _;
9123
9124 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
9125 if duplicate < 0 {
9126 return Err(std::io::Error::last_os_error().into());
9127 }
9128 let stream = unsafe { libc::fdopendir(duplicate) };
9129 if stream.is_null() {
9130 let error = std::io::Error::last_os_error();
9131 unsafe {
9132 libc::close(duplicate);
9133 }
9134 return Err(error.into());
9135 }
9136 let mut names = Vec::new();
9137 loop {
9138 let entry = unsafe { libc::readdir(stream) };
9139 if entry.is_null() {
9140 break;
9141 }
9142 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
9143 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
9144 names.push(raw.to_owned());
9145 }
9146 }
9147 if unsafe { libc::closedir(stream) } != 0 {
9148 return Err(std::io::Error::last_os_error().into());
9149 }
9150 Ok(names)
9151}
9152
9153#[cfg(unix)]
9156fn remove_tree_at(
9157 parent: std::os::fd::RawFd,
9158 name: &std::ffi::CStr,
9159 display: &str,
9160) -> LinkResult<()> {
9161 use std::os::fd::AsRawFd as _;
9162
9163 match entry_is_dir_at(parent, name)? {
9164 None => return Ok(()),
9165 Some(false) => {
9166 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
9167 return Err(std::io::Error::last_os_error().into());
9168 }
9169 }
9170 Some(true) => {
9171 let directory = open_dir_at(parent, name, display)?;
9172 for child in directory_entry_names(&directory)? {
9173 let child_display =
9174 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
9175 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
9176 }
9177 drop(directory);
9178 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
9179 return Err(std::io::Error::last_os_error().into());
9180 }
9181 }
9182 }
9183 Ok(())
9184}
9185
9186#[cfg(unix)]
9190fn clone_tree_contents(
9191 source: &std::fs::File,
9192 destination: &std::fs::File,
9193 display: &str,
9194) -> LinkResult<()> {
9195 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9196
9197 for name in directory_entry_names(source)? {
9198 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9199 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9200 if unsafe {
9201 libc::fstatat(
9202 source.as_raw_fd(),
9203 name.as_ptr(),
9204 &mut stat,
9205 libc::AT_SYMLINK_NOFOLLOW,
9206 )
9207 } != 0
9208 {
9209 return Err(std::io::Error::last_os_error().into());
9210 }
9211 match stat.st_mode & libc::S_IFMT {
9212 libc::S_IFDIR => {
9213 if unsafe {
9214 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
9215 } != 0
9216 {
9217 return Err(std::io::Error::last_os_error().into());
9218 }
9219 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
9220 let destination_child =
9221 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
9222 clone_tree_contents(&source_child, &destination_child, &child_display)?;
9223 destination_child.sync_all()?;
9224 }
9225 libc::S_IFREG => {
9226 let source_fd = unsafe {
9227 libc::openat(
9228 source.as_raw_fd(),
9229 name.as_ptr(),
9230 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9231 )
9232 };
9233 if source_fd < 0 {
9234 return Err(std::io::Error::last_os_error().into());
9235 }
9236 let destination_fd = unsafe {
9237 libc::openat(
9238 destination.as_raw_fd(),
9239 name.as_ptr(),
9240 libc::O_WRONLY
9241 | libc::O_CREAT
9242 | libc::O_EXCL
9243 | libc::O_CLOEXEC
9244 | libc::O_NOFOLLOW,
9245 (stat.st_mode & 0o777) as libc::c_uint,
9246 )
9247 };
9248 if destination_fd < 0 {
9249 unsafe {
9250 libc::close(source_fd);
9251 }
9252 return Err(std::io::Error::last_os_error().into());
9253 }
9254 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
9255 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
9256 std::io::copy(&mut input, &mut output)?;
9257 output.sync_all()?;
9258 }
9259 libc::S_IFLNK => {
9260 let mut target = vec![0_u8; 4097];
9261 let length = unsafe {
9262 libc::readlinkat(
9263 source.as_raw_fd(),
9264 name.as_ptr(),
9265 target.as_mut_ptr().cast(),
9266 target.len(),
9267 )
9268 };
9269 if length < 0 || length as usize >= target.len() {
9270 return Err(LinkError::UnsafePath {
9271 path: child_display,
9272 });
9273 }
9274 target.truncate(length as usize);
9275 let target = c_name(&target, &child_display)?;
9276 if unsafe {
9277 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
9278 } != 0
9279 {
9280 return Err(std::io::Error::last_os_error().into());
9281 }
9282 }
9283 _ => {
9284 return Err(LinkError::UnsafePath {
9285 path: child_display,
9286 });
9287 }
9288 }
9289 }
9290 destination.sync_all()?;
9291 Ok(())
9292}
9293
9294#[cfg(target_os = "linux")]
9295fn install_stage_at(
9296 parent: std::os::fd::RawFd,
9297 stage: &std::ffi::CStr,
9298 dest: &std::ffi::CStr,
9299 dest_exists: bool,
9300) -> LinkResult<()> {
9301 let flags = if dest_exists {
9302 libc::RENAME_EXCHANGE
9303 } else {
9304 libc::RENAME_NOREPLACE
9305 };
9306 let result = unsafe {
9310 libc::syscall(
9311 libc::SYS_renameat2,
9312 parent,
9313 stage.as_ptr(),
9314 parent,
9315 dest.as_ptr(),
9316 flags,
9317 )
9318 };
9319 if result == 0 {
9320 Ok(())
9321 } else {
9322 Err(std::io::Error::last_os_error().into())
9323 }
9324}
9325
9326#[cfg(target_os = "macos")]
9327fn install_stage_at(
9328 parent: std::os::fd::RawFd,
9329 stage: &std::ffi::CStr,
9330 dest: &std::ffi::CStr,
9331 dest_exists: bool,
9332) -> LinkResult<()> {
9333 let flags = if dest_exists {
9334 libc::RENAME_SWAP
9335 } else {
9336 libc::RENAME_EXCL
9337 };
9338 let result =
9339 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
9340 if result == 0 {
9341 Ok(())
9342 } else {
9343 Err(std::io::Error::last_os_error().into())
9344 }
9345}
9346
9347#[cfg(unix)]
9348fn write_pull_entries_beneath_dir(
9349 root: &std::fs::File,
9350 entries: &[(String, Vec<u8>)],
9351) -> LinkResult<()> {
9352 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9353
9354 for (path, content) in entries {
9355 let components: Vec<&str> = path.split('/').collect();
9356 let (leaf, parents) = components
9357 .split_last()
9358 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9359 let mut directory = root.try_clone()?;
9360 for component in parents {
9361 let name = c_name(component.as_bytes(), path)?;
9362 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9363 if made != 0 {
9364 let error = std::io::Error::last_os_error();
9365 if error.raw_os_error() != Some(libc::EEXIST) {
9366 return Err(error.into());
9367 }
9368 }
9369 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9370 }
9371
9372 let leaf_name = c_name(leaf.as_bytes(), path)?;
9373 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9374 let inspected = unsafe {
9375 libc::fstatat(
9376 directory.as_raw_fd(),
9377 leaf_name.as_ptr(),
9378 &mut existing,
9379 libc::AT_SYMLINK_NOFOLLOW,
9380 )
9381 };
9382 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
9383 return Err(LinkError::UnsafePath { path: path.clone() });
9384 }
9385
9386 let nonce = std::time::SystemTime::now()
9387 .duration_since(std::time::UNIX_EPOCH)
9388 .unwrap_or_default()
9389 .as_nanos();
9390 let temp_name = format!(
9391 ".dbmd-pull-{}-{nonce}-{}",
9392 std::process::id(),
9393 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
9394 );
9395 let temp = c_name(temp_name.as_bytes(), path)?;
9396 let fd = unsafe {
9397 libc::openat(
9398 directory.as_raw_fd(),
9399 temp.as_ptr(),
9400 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9401 0o600,
9402 )
9403 };
9404 if fd < 0 {
9405 return Err(std::io::Error::last_os_error().into());
9406 }
9407 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9408 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
9409 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9410 return Err(error.into());
9411 }
9412 drop(file);
9413 let renamed = unsafe {
9414 libc::renameat(
9415 directory.as_raw_fd(),
9416 temp.as_ptr(),
9417 directory.as_raw_fd(),
9418 leaf_name.as_ptr(),
9419 )
9420 };
9421 if renamed != 0 {
9422 let error = std::io::Error::last_os_error();
9423 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9424 return Err(error.into());
9425 }
9426 directory.sync_all()?;
9427 }
9428 root.sync_all()?;
9429 Ok(())
9430}
9431
9432#[cfg(unix)]
9433fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9434 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9435
9436 let path = &entry.path;
9437 let components: Vec<&str> = path.split('/').collect();
9438 let (leaf, parents) = components
9439 .split_last()
9440 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9441 let mut directory = root.try_clone()?;
9442 for component in parents {
9443 let name = c_name(component.as_bytes(), path)?;
9444 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9445 if made != 0 {
9446 let error = std::io::Error::last_os_error();
9447 if error.raw_os_error() != Some(libc::EEXIST) {
9448 return Err(error.into());
9449 }
9450 }
9451 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9452 }
9453 let leaf_name = c_name(leaf.as_bytes(), path)?;
9454 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9455 if unsafe {
9456 libc::fstatat(
9457 directory.as_raw_fd(),
9458 leaf_name.as_ptr(),
9459 &mut existing,
9460 libc::AT_SYMLINK_NOFOLLOW,
9461 )
9462 } == 0
9463 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
9464 {
9465 return Err(LinkError::UnsafePath { path: path.clone() });
9466 }
9467 let nonce = SystemTime::now()
9468 .duration_since(UNIX_EPOCH)
9469 .unwrap_or_default()
9470 .as_nanos();
9471 let temp_name = format!(
9472 ".dbmd-pull-{}-{nonce}-{}",
9473 std::process::id(),
9474 content_sha256(path.as_bytes())
9475 );
9476 let temp = c_name(temp_name.as_bytes(), path)?;
9477 let fd = unsafe {
9478 libc::openat(
9479 directory.as_raw_fd(),
9480 temp.as_ptr(),
9481 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9482 0o600,
9483 )
9484 };
9485 if fd < 0 {
9486 return Err(std::io::Error::last_os_error().into());
9487 }
9488 let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
9489 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
9490 let mut digest = Sha256::new();
9491 let mut total = 0_u64;
9492 let mut buffer = [0_u8; 64 * 1024];
9493 let copied = (|| -> std::io::Result<()> {
9494 loop {
9495 let read = input.read(&mut buffer)?;
9496 if read == 0 {
9497 break;
9498 }
9499 total = total.saturating_add(read as u64);
9500 if total > entry.bytes {
9501 return Err(std::io::Error::new(
9502 std::io::ErrorKind::InvalidData,
9503 "staged sync source grew beyond its verified length",
9504 ));
9505 }
9506 digest.update(&buffer[..read]);
9507 output.write_all(&buffer[..read])?;
9508 }
9509 Ok(())
9510 })();
9511 if let Err(error) = copied {
9512 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9513 return Err(error.into());
9514 }
9515 drop(output);
9516 if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
9517 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9518 return Err(invalid_feed(
9519 "private staged sync source failed final integrity verification",
9520 ));
9521 }
9522 if unsafe {
9523 libc::renameat(
9524 directory.as_raw_fd(),
9525 temp.as_ptr(),
9526 directory.as_raw_fd(),
9527 leaf_name.as_ptr(),
9528 )
9529 } != 0
9530 {
9531 let error = std::io::Error::last_os_error();
9532 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9533 return Err(error.into());
9534 }
9535 Ok(())
9536}
9537
9538#[cfg(unix)]
9539fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9540 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9541
9542 let path = &entry.path;
9543 let components: Vec<&str> = path.split('/').collect();
9544 let (leaf, parents) = components
9545 .split_last()
9546 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9547 let mut directory = root.try_clone()?;
9548 for component in parents {
9549 directory = open_dir_at(
9550 directory.as_raw_fd(),
9551 &c_name(component.as_bytes(), path)?,
9552 path,
9553 )?;
9554 }
9555 let leaf = c_name(leaf.as_bytes(), path)?;
9556 let fd = unsafe {
9557 libc::openat(
9558 directory.as_raw_fd(),
9559 leaf.as_ptr(),
9560 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9561 )
9562 };
9563 if fd < 0 {
9564 return Err(std::io::Error::last_os_error().into());
9565 }
9566 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9567 if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
9568 return Err(invalid_feed(
9569 "private pull stage changed before its durability barrier",
9570 ));
9571 }
9572 file.sync_all()?;
9573 Ok(())
9574}
9575
9576#[cfg(unix)]
9577fn run_pull_source_workers(
9578 root: &std::fs::File,
9579 entries: &[V2StagedFile],
9580 operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
9581) -> LinkResult<()> {
9582 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9583
9584 let next = AtomicUsize::new(0);
9585 let failed = AtomicBool::new(false);
9586 let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
9587 let mut first_error = None;
9588 std::thread::scope(|scope| {
9589 let (sender, receiver) = std::sync::mpsc::channel();
9590 for _ in 0..worker_count {
9591 let sender = sender.clone();
9592 let next = &next;
9593 let failed = &failed;
9594 scope.spawn(move || {
9595 while !failed.load(Ordering::Acquire) {
9596 let index = next.fetch_add(1, Ordering::Relaxed);
9597 let Some(entry) = entries.get(index) else {
9598 break;
9599 };
9600 let result = operation(root, entry);
9601 if result.is_err() {
9602 failed.store(true, Ordering::Release);
9603 }
9604 if sender.send(result).is_err() {
9605 break;
9606 }
9607 }
9608 });
9609 }
9610 drop(sender);
9611 for result in receiver {
9612 if let Err(error) = result {
9613 if first_error.is_none() {
9614 first_error = Some(error);
9615 }
9616 }
9617 }
9618 });
9619 if let Some(error) = first_error {
9620 return Err(error);
9621 }
9622 if next.load(Ordering::Relaxed) < entries.len() {
9623 return Err(invalid_feed(
9624 "a bounded pull worker stopped before reporting every file",
9625 ));
9626 }
9627 Ok(())
9628}
9629
9630#[cfg(unix)]
9631fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9632 use std::os::fd::AsRawFd as _;
9633
9634 for name in directory_entry_names(root)? {
9635 if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9636 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9637 let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9638 sync_pull_directory_tree(&child, &child_display)?;
9639 }
9640 }
9641 root.sync_all()?;
9642 Ok(())
9643}
9644
9645#[cfg(unix)]
9646fn write_pull_sources_beneath_dir(
9647 root: &std::fs::File,
9648 entries: &[V2StagedFile],
9649) -> LinkResult<()> {
9650 run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
9657 run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
9658 sync_pull_directory_tree(root, "v2 pull stage")
9659}
9660
9661#[cfg(unix)]
9662fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
9663 use std::os::fd::AsRawFd as _;
9664 for path in paths {
9665 if !safe_store_rel_path(path) {
9666 return Err(LinkError::UnsafePath { path: path.clone() });
9667 }
9668 let components = path.split('/').collect::<Vec<_>>();
9669 let Some((leaf, parents)) = components.split_last() else {
9670 return Err(LinkError::UnsafePath { path: path.clone() });
9671 };
9672 let mut directory = root.try_clone()?;
9673 let mut missing = false;
9674 for component in parents {
9675 let name = c_name(component.as_bytes(), path)?;
9676 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
9677 None => {
9678 missing = true;
9679 break;
9680 }
9681 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
9682 Some(true) => {
9683 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9684 }
9685 }
9686 }
9687 if missing {
9688 continue;
9689 }
9690 let leaf = c_name(leaf.as_bytes(), path)?;
9691 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
9692 None => {}
9693 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
9694 Some(false) => {
9695 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
9696 return Err(std::io::Error::last_os_error().into());
9697 }
9698 directory.sync_all()?;
9699 }
9700 }
9701 }
9702 Ok(())
9703}
9704
9705#[cfg(unix)]
9706fn install_pulled_delta(
9707 dest: &Path,
9708 entries: &[(String, Vec<u8>)],
9709 deleted: &[String],
9710 rebuild_indexes: bool,
9711) -> LinkResult<()> {
9712 use ring::rand::SecureRandom as _;
9713 use std::os::fd::AsRawFd as _;
9714 use std::os::unix::ffi::OsStrExt as _;
9715
9716 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9717 let name = dest
9718 .file_name()
9719 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9720 .ok_or_else(|| LinkError::UnsafePath {
9721 path: dest.display().to_string(),
9722 })?;
9723 let parent_dir = open_or_create_dir_nofollow(parent)?;
9724 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9725 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9726 None => false,
9727 Some(true) => true,
9728 Some(false) => {
9729 return Err(LinkError::UnsafePath {
9730 path: dest.display().to_string(),
9731 });
9732 }
9733 };
9734
9735 let mut nonce = [0_u8; 16];
9736 ring::rand::SystemRandom::new()
9737 .fill(&mut nonce)
9738 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9739 let stage_label = format!(
9740 ".{}.dbmd-pull-stage-{}",
9741 name.to_string_lossy(),
9742 URL_SAFE_NO_PAD.encode(nonce)
9743 );
9744 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9745 let stage_dir = create_dir_exclusive_at(
9746 parent_dir.as_raw_fd(),
9747 &stage_name,
9748 &dest.display().to_string(),
9749 )?;
9750
9751 let prepared = (|| -> LinkResult<()> {
9752 if dest_exists {
9753 let live = open_dir_at(
9754 parent_dir.as_raw_fd(),
9755 &dest_name,
9756 &dest.display().to_string(),
9757 )?;
9758 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9759 }
9760 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9761 write_pull_entries_beneath_dir(&stage_dir, entries)?;
9762 if rebuild_indexes {
9763 let stage_store =
9764 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
9765 .map_err(|error| LinkError::InvalidPack {
9766 message: format!("v2 staging tree is not a valid db.md store: {error}"),
9767 })?;
9768 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
9769 LinkError::InvalidPack {
9770 message: format!("could not materialize v2 local catalogs: {error}"),
9771 }
9772 })?;
9773 }
9774 stage_dir.sync_all()?;
9775 Ok(())
9776 })();
9777 if let Err(error) = prepared {
9778 let _ = remove_tree_at(
9779 parent_dir.as_raw_fd(),
9780 &stage_name,
9781 &dest.display().to_string(),
9782 );
9783 return Err(error);
9784 }
9785
9786 if let Err(error) =
9787 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
9788 {
9789 let _ = remove_tree_at(
9790 parent_dir.as_raw_fd(),
9791 &stage_name,
9792 &dest.display().to_string(),
9793 );
9794 return Err(error);
9795 }
9796 parent_dir.sync_all()?;
9797 if dest_exists {
9798 let _ = remove_tree_at(
9802 parent_dir.as_raw_fd(),
9803 &stage_name,
9804 &dest.display().to_string(),
9805 );
9806 let _ = parent_dir.sync_all();
9807 }
9808 Ok(())
9809}
9810
9811#[cfg(unix)]
9812fn install_pulled_delta_sources(
9813 dest: &Path,
9814 entries: &[V2StagedFile],
9815 deleted: &[String],
9816 rebuild_indexes: bool,
9817 _previous: Option<&V2SyncBaseline>,
9818 _next: &V2VerifiedHead,
9819) -> LinkResult<()> {
9820 use ring::rand::SecureRandom as _;
9821 use std::os::fd::AsRawFd as _;
9822 use std::os::unix::ffi::OsStrExt as _;
9823
9824 if let Ok(store) = Store::open_strict(dest) {
9828 return install_established_v2_delta(
9829 store,
9830 entries,
9831 deleted,
9832 rebuild_indexes,
9833 _previous,
9834 _next,
9835 );
9836 }
9837
9838 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9839 let name = dest
9840 .file_name()
9841 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9842 .ok_or_else(|| LinkError::UnsafePath {
9843 path: dest.display().to_string(),
9844 })?;
9845 let parent_dir = open_or_create_dir_nofollow(parent)?;
9846 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9847 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9848 None => false,
9849 Some(true) => true,
9850 Some(false) => {
9851 return Err(LinkError::UnsafePath {
9852 path: dest.display().to_string(),
9853 })
9854 }
9855 };
9856 let mut nonce = [0_u8; 16];
9857 ring::rand::SystemRandom::new()
9858 .fill(&mut nonce)
9859 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9860 let stage_label = format!(
9861 ".{}.dbmd-pull-stage-{}",
9862 name.to_string_lossy(),
9863 URL_SAFE_NO_PAD.encode(nonce)
9864 );
9865 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9866 let stage_dir = create_dir_exclusive_at(
9867 parent_dir.as_raw_fd(),
9868 &stage_name,
9869 &dest.display().to_string(),
9870 )?;
9871 let prepared = (|| -> LinkResult<()> {
9872 if dest_exists {
9873 let live = open_dir_at(
9874 parent_dir.as_raw_fd(),
9875 &dest_name,
9876 &dest.display().to_string(),
9877 )?;
9878 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9879 }
9880 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9881 write_pull_sources_beneath_dir(&stage_dir, entries)?;
9882 if rebuild_indexes {
9883 let stage_store =
9884 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
9885 .map_err(|error| LinkError::InvalidPack {
9886 message: format!("v2 staging tree is not a valid db.md store: {error}"),
9887 })?;
9888 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
9889 LinkError::InvalidPack {
9890 message: format!("could not materialize v2 local catalogs: {error}"),
9891 }
9892 })?;
9893 }
9894 stage_dir.sync_all()?;
9895 Ok(())
9896 })();
9897 if let Err(error) = prepared {
9898 let _ = remove_tree_at(
9899 parent_dir.as_raw_fd(),
9900 &stage_name,
9901 &dest.display().to_string(),
9902 );
9903 return Err(error);
9904 }
9905 if let Err(error) =
9906 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
9907 {
9908 let _ = remove_tree_at(
9909 parent_dir.as_raw_fd(),
9910 &stage_name,
9911 &dest.display().to_string(),
9912 );
9913 return Err(error);
9914 }
9915 parent_dir.sync_all()?;
9916 if dest_exists {
9917 let _ = remove_tree_at(
9918 parent_dir.as_raw_fd(),
9919 &stage_name,
9920 &dest.display().to_string(),
9921 );
9922 let _ = parent_dir.sync_all();
9923 }
9924 Ok(())
9925}
9926
9927#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9928struct V2PullCoordinate {
9929 head_seq: Option<u64>,
9930 commit_hash: Option<String>,
9931 view_kind: Option<String>,
9932 view_revision: Option<String>,
9933}
9934
9935#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9936struct V2PullFileCoordinate {
9937 sha256: String,
9938 bytes: u64,
9939}
9940
9941#[derive(Debug, Clone, Deserialize, Serialize)]
9942struct V2PullJournalEntry {
9943 path: String,
9944 old: Option<V2PullFileCoordinate>,
9945 new: Option<V2PullFileCoordinate>,
9946 backup: Option<String>,
9947}
9948
9949#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9950#[serde(rename_all = "snake_case")]
9951enum V2PullPhase {
9952 Preparing,
9953 Ready,
9954}
9955
9956#[derive(Debug, Clone, Deserialize, Serialize)]
9957struct V2PullJournal {
9958 v: u8,
9959 phase: V2PullPhase,
9960 brain: String,
9961 previous: V2PullCoordinate,
9962 next: V2PullCoordinate,
9963 backup_dir: String,
9964 entries: Vec<V2PullJournalEntry>,
9965}
9966
9967const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
9968
9969fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
9970 V2PullCoordinate {
9971 head_seq: baseline.and_then(|value| value.head_seq),
9972 commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
9973 view_kind: baseline.and_then(|value| value.view_kind.clone()),
9974 view_revision: baseline.and_then(|value| value.view_revision.clone()),
9975 }
9976}
9977
9978fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
9979 V2PullCoordinate {
9980 head_seq: head.pointer.as_ref().map(|value| value.seq),
9981 commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
9982 view_kind: Some(head.view_kind.clone()),
9983 view_revision: Some(head.view_revision.clone()),
9984 }
9985}
9986
9987fn v2_pull_file_coordinate(
9988 store: &Store,
9989 path: &str,
9990 limit: u64,
9991) -> LinkResult<Option<V2PullFileCoordinate>> {
9992 let file = match store.open_regular(Path::new(path)) {
9993 Ok(file) => file,
9994 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9995 Err(error) => return Err(error.into()),
9996 };
9997 let bytes = file.metadata()?.len();
9998 if bytes > limit || bytes > MAX_STORE_BYTES {
9999 return Err(invalid_feed(
10000 "pull transaction file exceeds its declared bound",
10001 ));
10002 }
10003 Ok(Some(V2PullFileCoordinate {
10004 sha256: content_sha256_reader(file)?,
10005 bytes,
10006 }))
10007}
10008
10009fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
10010 let mut bytes = serde_json::to_vec_pretty(journal)
10011 .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
10012 bytes.push(b'\n');
10013 Ok(bytes)
10014}
10015
10016fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
10017 let backup_prefix = ".dbmd/pull-backup-";
10018 let suffix = journal
10019 .backup_dir
10020 .strip_prefix(backup_prefix)
10021 .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
10022 let mut paths = std::collections::BTreeSet::new();
10023 if journal.v != 1
10024 || !crate::ulid::is_ulid(&journal.brain)
10025 || !crate::ulid::is_ulid(suffix)
10026 || journal.entries.is_empty()
10027 || journal.entries.len() > MAX_PUSH_FILES + 4
10028 || journal.previous == journal.next
10029 {
10030 return Err(invalid_feed("v2 pull journal failed validation"));
10031 }
10032 for (index, entry) in journal.entries.iter().enumerate() {
10033 if !safe_store_rel_path(&entry.path)
10034 || entry.path == V2_PULL_JOURNAL
10035 || entry.path.starts_with(backup_prefix)
10036 || !paths.insert(entry.path.clone())
10037 || (entry.old.is_none() && entry.new.is_none())
10038 || entry
10039 .old
10040 .iter()
10041 .chain(entry.new.iter())
10042 .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
10043 || entry.backup.as_deref()
10044 != entry
10045 .old
10046 .as_ref()
10047 .map(|_| format!("{index:08x}"))
10048 .as_deref()
10049 {
10050 return Err(invalid_feed("v2 pull journal entry failed validation"));
10051 }
10052 }
10053 Ok(())
10054}
10055
10056fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
10057 #[cfg(unix)]
10058 {
10059 use std::os::unix::fs::PermissionsExt as _;
10060 match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
10061 Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
10062 return Err(invalid_feed(
10063 "v2 pull journal is accessible to group/other; set mode 0600",
10064 ));
10065 }
10066 Ok(_) => {}
10067 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10068 Err(error) => return Err(error.into()),
10069 }
10070 }
10071 let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
10072 Ok(bytes) => bytes,
10073 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10074 Err(error) => return Err(error.into()),
10075 };
10076 let journal: V2PullJournal =
10077 serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
10078 validate_v2_pull_journal(&journal)?;
10079 Ok(Some(journal))
10080}
10081
10082fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10083 match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
10087 Ok(()) => {}
10088 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
10089 Err(error) => return Err(error.into()),
10090 }
10091 match store.remove_private_tree(Path::new(&journal.backup_dir)) {
10092 Ok(()) => Ok(()),
10093 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
10094 Err(error) => Err(error.into()),
10095 }
10096}
10097
10098fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
10099 let names = match store.directory_names(Path::new(".dbmd")) {
10100 Ok(names) => names,
10101 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
10102 Err(error) => return Err(error.into()),
10103 };
10104 for name in names {
10105 let Some(name) = name.to_str() else {
10106 continue;
10107 };
10108 let Some(suffix) = name.strip_prefix("pull-backup-") else {
10109 continue;
10110 };
10111 if crate::ulid::is_ulid(suffix) {
10112 store.remove_private_tree(&Path::new(".dbmd").join(name))?;
10113 }
10114 }
10115 Ok(())
10116}
10117
10118fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10119 for entry in &journal.entries {
10121 let limit = entry
10122 .old
10123 .as_ref()
10124 .into_iter()
10125 .chain(entry.new.iter())
10126 .map(|value| value.bytes)
10127 .max()
10128 .unwrap_or(0);
10129 let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
10130 if current != entry.old && current != entry.new {
10131 return Err(LinkError::InvalidPack {
10132 message: format!(
10133 "cannot recover interrupted pull because `{}` changed afterward",
10134 entry.path
10135 ),
10136 });
10137 }
10138 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10139 let path = Path::new(&journal.backup_dir).join(backup);
10140 let file = store.open_regular(&path)?;
10141 if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
10142 return Err(invalid_feed("v2 pull recovery backup failed verification"));
10143 }
10144 }
10145 }
10146 for entry in journal.entries.iter().rev() {
10147 match (&entry.old, &entry.backup) {
10148 (Some(old), Some(backup)) => {
10149 let bytes =
10150 store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
10151 store.write_atomic(Path::new(&entry.path), &bytes)?;
10152 }
10153 (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
10154 store.remove_file(Path::new(&entry.path))?;
10155 }
10156 (None, None) => {}
10157 _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
10158 }
10159 }
10160 crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
10161 message: format!("could not rebuild catalogs after pull recovery: {error}"),
10162 })?;
10163 cleanup_v2_pull_journal(store, journal)
10164}
10165
10166fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
10167 let Ok(store) = Store::open_strict(dest) else {
10168 return Ok(());
10169 };
10170 if let Some(journal) = load_v2_pull_journal(&store)? {
10171 if journal.brain != brain {
10172 return Err(invalid_feed("v2 pull journal belongs to another brain"));
10173 }
10174 if journal.phase == V2PullPhase::Preparing {
10175 cleanup_v2_pull_journal(&store, &journal)?;
10176 } else {
10177 let baseline = load_v2_baseline(cfg, brain, dest)?;
10178 let current = v2_pull_baseline_coordinate(baseline.as_ref());
10179 if current == journal.next {
10180 cleanup_v2_pull_journal(&store, &journal)?;
10181 } else {
10182 if current != journal.previous {
10183 return Err(invalid_feed(
10184 "cannot recover interrupted pull because its baseline changed afterward",
10185 ));
10186 }
10187 rollback_v2_pull(&store, &journal)?;
10188 }
10189 }
10190 }
10191 prune_orphan_v2_pull_backups(&store)
10196}
10197
10198fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
10199 let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
10200 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
10201 })?;
10202 if let Some(journal) = load_v2_pull_journal(&store)? {
10203 cleanup_v2_pull_journal(&store, &journal)?;
10204 }
10205 Ok(())
10206}
10207
10208#[cfg(windows)]
10209fn install_windows_initial_sources(
10210 dest: &Path,
10211 entries: &[V2StagedFile],
10212 rebuild_indexes: bool,
10213) -> LinkResult<()> {
10214 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10215 let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
10216 path: dest.display().to_string(),
10217 })?;
10218 let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
10219 if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
10220 return Err(LinkError::UnsafePath {
10221 path: dest.display().to_string(),
10222 });
10223 }
10224 let stage_name = format!(
10225 ".{}.dbmd-pull-stage-{}",
10226 name.to_string_lossy(),
10227 crate::ulid::mint()
10228 );
10229 let stage_path = parent.join(&stage_name);
10230 let stage_capability =
10231 crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
10232 let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
10233 let prepared = (|| -> LinkResult<()> {
10234 for entry in entries {
10235 let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
10236 if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
10237 return Err(invalid_feed(
10238 "private staged sync source failed final integrity verification",
10239 ));
10240 }
10241 stage.write_atomic(Path::new(&entry.path), &bytes)?;
10242 }
10243 let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
10244 .map_err(|error| LinkError::InvalidPack {
10245 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10246 })?;
10247 if rebuild_indexes {
10248 crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
10249 message: format!("could not materialize v2 local catalogs: {error}"),
10250 })?;
10251 }
10252 Ok(())
10253 })();
10254 if let Err(error) = prepared {
10255 let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
10256 return Err(error);
10257 }
10258 crate::fsx::rename_directory_beneath(
10259 &parent_capability,
10260 Path::new(&stage_name),
10261 Path::new(name),
10262 )?;
10263 Ok(())
10264}
10265
10266fn install_established_v2_delta(
10267 store: Store,
10268 entries: &[V2StagedFile],
10269 deleted: &[String],
10270 rebuild_indexes: bool,
10271 previous: Option<&V2SyncBaseline>,
10272 next: &V2VerifiedHead,
10273) -> LinkResult<()> {
10274 if load_v2_pull_journal(&store)?.is_some() {
10275 return Err(invalid_feed(
10276 "an interrupted pull must be recovered before installing",
10277 ));
10278 }
10279 let mut sources = std::collections::BTreeMap::new();
10280 for entry in entries {
10281 if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
10282 return Err(invalid_feed("pull mutation repeats a path"));
10283 }
10284 }
10285 let mut paths = sources.keys().cloned().collect::<Vec<_>>();
10286 paths.extend(deleted.iter().cloned());
10287 paths.sort();
10288 paths.dedup();
10289 if paths.is_empty() {
10290 return Ok(());
10291 }
10292 let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
10293 let mut journal = V2PullJournal {
10294 v: 1,
10295 phase: V2PullPhase::Preparing,
10296 brain: next.brain_id.clone(),
10297 previous: v2_pull_baseline_coordinate(previous),
10298 next: v2_pull_head_coordinate(next),
10299 backup_dir: backup_dir.clone(),
10300 entries: Vec::with_capacity(paths.len()),
10301 };
10302 for path in &paths {
10303 let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
10304 let new = sources.get(path).map(|entry| V2PullFileCoordinate {
10305 sha256: entry.sha256.clone(),
10306 bytes: entry.bytes,
10307 });
10308 if old == new {
10309 continue;
10310 }
10311 let index = journal.entries.len();
10312 journal.entries.push(V2PullJournalEntry {
10313 path: path.clone(),
10314 backup: old.as_ref().map(|_| format!("{index:08x}")),
10315 old,
10316 new,
10317 });
10318 }
10319 if journal.entries.is_empty() {
10320 return Ok(());
10321 }
10322 let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
10323 entry
10324 .old
10325 .as_ref()
10326 .map_or(Some(total), |old| total.checked_add(old.bytes))
10327 });
10328 if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
10329 return Err(LinkError::InvalidPack {
10330 message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
10331 });
10332 }
10333 validate_v2_pull_journal(&journal)?;
10334 store.write_private_atomic_new(
10335 Path::new(V2_PULL_JOURNAL),
10336 &v2_pull_journal_bytes(&journal)?,
10337 )?;
10338 let prepared = (|| -> LinkResult<()> {
10339 store.create_private_dir_all(Path::new(&backup_dir))?;
10340 for entry in &journal.entries {
10341 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10342 let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
10343 if content_sha256(&bytes) != old.sha256 {
10344 return Err(invalid_feed("live pull source changed during backup"));
10345 }
10346 store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
10347 }
10348 }
10349 journal.phase = V2PullPhase::Ready;
10350 store.write_private_atomic(
10351 Path::new(V2_PULL_JOURNAL),
10352 &v2_pull_journal_bytes(&journal)?,
10353 )?;
10354 Ok(())
10355 })();
10356 if let Err(error) = prepared {
10357 let cleanup = cleanup_v2_pull_journal(&store, &journal);
10358 return match cleanup {
10359 Ok(()) => Err(error),
10360 Err(cleanup) => Err(LinkError::InvalidPack {
10361 message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
10362 }),
10363 };
10364 }
10365 let installed = (|| -> LinkResult<()> {
10366 for entry in &journal.entries {
10367 if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
10368 return Err(LinkError::InvalidPack {
10369 message: format!("local path `{}` changed during pull", entry.path),
10370 });
10371 }
10372 if let Some(source) = sources.get(&entry.path) {
10373 let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
10374 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
10375 return Err(invalid_feed(
10376 "private staged sync source failed final integrity verification",
10377 ));
10378 }
10379 store.write_atomic(Path::new(&entry.path), &bytes)?;
10380 } else if entry.old.is_some() {
10381 store.remove_file(Path::new(&entry.path))?;
10382 }
10383 }
10384 if rebuild_indexes {
10385 crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
10386 message: format!("could not materialize v2 local catalogs: {error}"),
10387 })?;
10388 }
10389 Ok(())
10390 })();
10391 if let Err(error) = installed {
10392 return match rollback_v2_pull(&store, &journal) {
10393 Ok(()) => Err(error),
10394 Err(rollback) => Err(LinkError::InvalidPack {
10395 message: format!("{error}; durable pull rollback also failed: {rollback}"),
10396 }),
10397 };
10398 }
10399 Ok(())
10400}
10401
10402#[cfg(windows)]
10403fn install_pulled_delta_sources(
10404 dest: &Path,
10405 entries: &[V2StagedFile],
10406 deleted: &[String],
10407 rebuild_indexes: bool,
10408 previous: Option<&V2SyncBaseline>,
10409 next: &V2VerifiedHead,
10410) -> LinkResult<()> {
10411 match Store::open_strict(dest) {
10412 Ok(store) => {
10413 install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
10414 }
10415 Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
10416 }
10417}
10418
10419#[cfg(not(any(unix, windows)))]
10420fn install_pulled_delta_sources(
10421 _dest: &Path,
10422 _entries: &[V2StagedFile],
10423 _deleted: &[String],
10424 _rebuild_indexes: bool,
10425 _previous: Option<&V2SyncBaseline>,
10426 _next: &V2VerifiedHead,
10427) -> LinkResult<()> {
10428 Err(LinkError::UnsupportedPlatform {
10429 operation: "atomic v2 pull install",
10430 })
10431}
10432
10433#[cfg(unix)]
10434fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
10435 install_pulled_delta(dest, entries, &[], false)
10436}
10437
10438#[cfg(not(windows))]
10439fn is_safe_slug(slug: &str) -> bool {
10440 !slug.is_empty()
10441 && slug.len() <= 63
10442 && !slug.starts_with('-')
10443 && !slug.ends_with('-')
10444 && slug
10445 .bytes()
10446 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
10447}
10448
10449fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
10450 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
10451}
10452
10453fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
10454 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
10455}
10456
10457fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
10458 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
10459}
10460
10461fn preflight_zip_central_directory(
10462 bytes: &[u8],
10463 offset: usize,
10464 size: usize,
10465 count: u64,
10466) -> LinkResult<()> {
10467 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
10468 let end = offset
10469 .checked_add(size)
10470 .filter(|end| *end <= bytes.len())
10471 .ok_or_else(|| LinkError::InvalidPack {
10472 message: "ZIP central directory is out of bounds".to_string(),
10473 })?;
10474 let mut cursor = offset;
10475 for _ in 0..count {
10476 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
10477 return Err(LinkError::InvalidPack {
10478 message: "ZIP central directory entry count is inconsistent".to_string(),
10479 });
10480 }
10481 if le_u16(bytes, cursor + 34) != Some(0) {
10482 return Err(LinkError::InvalidPack {
10483 message: "multi-disk ZIP archives are not supported".to_string(),
10484 });
10485 }
10486 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
10487 total.checked_add(le_u16(bytes, cursor + at)? as usize)
10488 });
10489 cursor = cursor
10490 .checked_add(46)
10491 .and_then(|fixed| fixed.checked_add(variable?))
10492 .filter(|cursor| *cursor <= end)
10493 .ok_or_else(|| LinkError::InvalidPack {
10494 message: "ZIP central directory entry is truncated".to_string(),
10495 })?;
10496 }
10497 if cursor != end {
10498 return Err(LinkError::InvalidPack {
10499 message: "ZIP central directory size is inconsistent".to_string(),
10500 });
10501 }
10502 Ok(())
10503}
10504
10505fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
10509 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
10510 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
10511 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
10512 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
10513 let eocd = bytes[search_start..]
10514 .windows(4)
10515 .rposition(|window| window == EOCD_SIG)
10516 .map(|offset| search_start + offset)
10517 .ok_or_else(|| LinkError::InvalidPack {
10518 message: "ZIP has no end-of-central-directory record".to_string(),
10519 })?;
10520 let invalid_end = || LinkError::InvalidPack {
10521 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
10522 };
10523 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
10524 if eocd
10525 .checked_add(22)
10526 .and_then(|end| end.checked_add(comment_len))
10527 != Some(bytes.len())
10528 {
10529 return Err(invalid_end());
10533 }
10534 let disk = le_u16(bytes, eocd + 4);
10535 let central_disk = le_u16(bytes, eocd + 6);
10536 if disk != Some(0) || central_disk != Some(0) {
10537 return Err(LinkError::InvalidPack {
10538 message: "multi-disk ZIP archives are not supported".to_string(),
10539 });
10540 }
10541 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
10542 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
10543 if entries_on_disk != ordinary {
10544 return Err(LinkError::InvalidPack {
10545 message: "multi-disk ZIP archives are not supported".to_string(),
10546 });
10547 }
10548 let zip64_locator = eocd
10549 .checked_sub(20)
10550 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
10551 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
10552 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
10553 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
10554 if central_offset
10555 .checked_add(central_size)
10556 .filter(|end| *end == eocd)
10557 .is_none()
10558 {
10559 return Err(invalid_end());
10560 }
10561 (ordinary as u64, central_offset, central_size)
10562 } else {
10563 let Some(locator) = zip64_locator else {
10564 return Err(invalid_end());
10565 };
10566 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
10567 return Err(LinkError::InvalidPack {
10568 message: "multi-disk ZIP64 archives are not supported".to_string(),
10569 });
10570 }
10571 let record = le_u64(bytes, locator + 8)
10572 .and_then(|offset| usize::try_from(offset).ok())
10573 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
10574 .ok_or_else(|| LinkError::InvalidPack {
10575 message: "ZIP64 archive has an invalid end record".to_string(),
10576 })?;
10577 let record_size = le_u64(bytes, record + 4)
10578 .and_then(|size| usize::try_from(size).ok())
10579 .filter(|size| *size >= 44)
10580 .ok_or_else(invalid_end)?;
10581 if record
10582 .checked_add(12)
10583 .and_then(|end| end.checked_add(record_size))
10584 != Some(locator)
10585 || le_u32(bytes, record + 16) != Some(0)
10586 || le_u32(bytes, record + 20) != Some(0)
10587 {
10588 return Err(invalid_end());
10589 }
10590 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
10591 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
10592 let central_size = le_u64(bytes, record + 40)
10593 .and_then(|size| usize::try_from(size).ok())
10594 .ok_or_else(invalid_end)?;
10595 let central_offset = le_u64(bytes, record + 48)
10596 .and_then(|offset| usize::try_from(offset).ok())
10597 .ok_or_else(invalid_end)?;
10598 if zip64_on_disk != zip64_total
10599 || central_offset
10600 .checked_add(central_size)
10601 .filter(|end| *end == record)
10602 .is_none()
10603 {
10604 return Err(invalid_end());
10605 }
10606 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10607 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10608 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10609 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10610 {
10611 return Err(invalid_end());
10612 }
10613 (zip64_total, central_offset, central_size)
10614 };
10615 if count == 0 || count > max_entries as u64 {
10616 return Err(LinkError::InvalidPack {
10617 message: format!("invalid file count {count}"),
10618 });
10619 }
10620 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10621 Ok(())
10622}
10623
10624fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10625 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10626 let mut archive =
10627 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10628 message: format!("ZIP parse failed: {err}"),
10629 })?;
10630 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10631 return Err(LinkError::InvalidPack {
10632 message: format!("invalid file count {}", archive.len()),
10633 });
10634 }
10635 let mut total = 0u64;
10636 let mut seen = std::collections::HashSet::new();
10637 let mut entries = Vec::with_capacity(archive.len());
10638 for index in 0..archive.len() {
10639 let mut file = archive
10640 .by_index(index)
10641 .map_err(|err| LinkError::InvalidPack {
10642 message: format!("ZIP entry failed: {err}"),
10643 })?;
10644 if file.is_dir() {
10645 continue;
10646 }
10647 let path = file.name().to_string();
10648 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10649 return Err(LinkError::UnsafePath { path });
10650 }
10651 if file
10652 .unix_mode()
10653 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
10654 {
10655 return Err(LinkError::InvalidPack {
10656 message: format!("non-file entry `{path}`"),
10657 });
10658 }
10659 if !seen.insert(path.clone()) {
10660 return Err(LinkError::InvalidPack {
10661 message: format!("duplicate path `{path}`"),
10662 });
10663 }
10664 let remaining = MAX_STORE_BYTES.saturating_sub(total);
10665 if file.size() > remaining {
10666 return Err(LinkError::InvalidPack {
10667 message: "expanded content exceeds the 512 MB limit".to_string(),
10668 });
10669 }
10670 let mut content = Vec::new();
10671 (&mut file)
10672 .take(remaining + 1)
10673 .read_to_end(&mut content)
10674 .map_err(|err| LinkError::InvalidPack {
10675 message: format!("could not decompress `{path}`: {err}"),
10676 })?;
10677 if content.len() as u64 > remaining {
10678 return Err(LinkError::InvalidPack {
10679 message: "expanded content exceeds the 512 MB limit".to_string(),
10680 });
10681 }
10682 if content.len() as u64 != file.size() {
10683 return Err(LinkError::InvalidPack {
10684 message: format!("length mismatch for `{path}`"),
10685 });
10686 }
10687 total += content.len() as u64;
10688 entries.push((path, content));
10689 }
10690 if entries.is_empty() {
10691 return Err(LinkError::InvalidPack {
10692 message: "pack contains no files".to_string(),
10693 });
10694 }
10695 Ok(entries)
10696}
10697
10698fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
10699 let mut expected = std::collections::BTreeMap::new();
10700 for file in signed {
10701 if !safe_store_rel_path(&file.path) {
10702 return Err(LinkError::UnsafePath {
10703 path: file.path.clone(),
10704 });
10705 }
10706 if !is_sha256(&file.sha256)
10707 || expected
10708 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10709 .is_some()
10710 {
10711 return Err(invalid_feed(
10712 "signed snapshot manifest contains an invalid or duplicate file",
10713 ));
10714 }
10715 }
10716 if expected.len() != entries.len() {
10717 return Err(invalid_feed(
10718 "downloaded pack file set differs from the signed snapshot manifest",
10719 ));
10720 }
10721 for (path, bytes) in entries {
10722 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
10723 return Err(invalid_feed(format!(
10724 "downloaded pack contains unsigned path `{path}`"
10725 )));
10726 };
10727 if *declared_bytes != bytes.len() as u64
10728 || *sha256 != format!("{:x}", Sha256::digest(bytes))
10729 {
10730 return Err(invalid_feed(format!(
10731 "downloaded file `{path}` differs from its signed manifest"
10732 )));
10733 }
10734 }
10735 Ok(())
10736}
10737
10738pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
10745 require_hardened_filesystem("sync push")?;
10746 preflight_push_ownership(store)?;
10747 let mut out: Vec<(String, String)> = Vec::new();
10748 let mut total = 0u64;
10749
10750 let mut read_text = |rel: &str| -> LinkResult<String> {
10751 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
10752 total = total
10753 .checked_add(bytes.len() as u64)
10754 .ok_or_else(|| LinkError::PushTooLarge {
10755 detail: "uncompressed byte count overflow".to_string(),
10756 })?;
10757 if total > MAX_STORE_BYTES {
10758 return Err(LinkError::PushTooLarge {
10759 detail: format!("{total} uncompressed bytes"),
10760 });
10761 }
10762 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
10763 path: rel.to_string(),
10764 })
10765 };
10766
10767 out.push(("DB.md".to_string(), read_text("DB.md")?));
10768 if store
10769 .regular_file_exists(Path::new("assets.jsonl"))
10770 .unwrap_or(false)
10771 {
10772 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
10773 }
10774
10775 for rel in store.walk()? {
10776 let rel_str = rel.to_string_lossy().replace('\\', "/");
10777 if !safe_store_rel_path(&rel_str) {
10778 return Err(LinkError::UnsafePath { path: rel_str });
10781 }
10782 let content = read_text(&rel_str)?;
10783 out.push((rel_str, content));
10784 }
10785
10786 out.sort_by(|a, b| a.0.cmp(&b.0));
10787 Ok(out)
10788}
10789
10790fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
10794 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
10795 return Err(LinkError::from(std::io::Error::new(
10796 std::io::ErrorKind::PermissionDenied,
10797 format!("cannot push: nested db.md store at {}", nested.display()),
10798 )));
10799 }
10800
10801 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
10802 return Err(LinkError::from(std::io::Error::new(
10803 std::io::ErrorKind::PermissionDenied,
10804 format!(
10805 "cannot push: {} is a symlink outside the store ownership model",
10806 symlink.display()
10807 ),
10808 )));
10809 }
10810 Ok(())
10811}
10812
10813pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
10819 require_safe_ref(brain)?;
10820 let remote = verified_remote_head(cfg, brain, false)?;
10821 if files.len() > MAX_PUSH_FILES {
10822 return Err(LinkError::PushTooLarge {
10823 detail: format!("{} files", files.len()),
10824 });
10825 }
10826 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
10827 if raw_total > MAX_STORE_BYTES {
10828 return Err(LinkError::PushTooLarge {
10829 detail: format!("{raw_total} uncompressed bytes"),
10830 });
10831 }
10832
10833 if cfg.brain_key.is_none() {
10837 let body = json!({
10838 "files": files
10839 .iter()
10840 .map(|(p, c)| json!({ "path": p, "content": c }))
10841 .collect::<Vec<_>>(),
10842 });
10843 if body.to_string().len() <= MAX_PUSH_BYTES {
10844 let path = format!("/api/hub/brains/{brain}/push");
10845 let pushed = ensure_ok(
10846 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10847 "sync push",
10848 )?;
10849 return Ok(pushed);
10850 }
10851 }
10852
10853 let pack = build_store_pack(files)?;
10854 if pack.len() as u64 > MAX_PACK_BYTES {
10855 return Err(LinkError::PushTooLarge {
10856 detail: format!("{} pack bytes", pack.len()),
10857 });
10858 }
10859 let sha256 = format!("{:x}", Sha256::digest(&pack));
10860 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
10861 if let Some(key) = &cfg.brain_key {
10862 if !remote.head.verified {
10863 return Err(invalid_feed(
10864 "self-custody push requires a fully verified, unscoped feed head",
10865 ));
10866 }
10867 let identity = remote
10868 .identity
10869 .as_ref()
10870 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
10871 let current_multikey = format!("ed25519:{}", identity.fingerprint);
10872 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
10873 return Err(invalid_feed(
10874 "configured brain key is not the verified current brain identity",
10875 ));
10876 }
10877 let next_seq = remote
10880 .head
10881 .seq
10882 .checked_add(1)
10883 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
10884 let mut manifest: Vec<WireFeedFile> = files
10885 .iter()
10886 .map(|(path, content)| WireFeedFile {
10887 path: path.clone(),
10888 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
10889 bytes: content.len() as u64,
10890 })
10891 .collect();
10892 manifest.sort_by(|a, b| a.path.cmp(&b.path));
10893 let ts = crate::now()
10894 .with_timezone(&chrono::Utc)
10895 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
10896 .to_string();
10897 let entry = self_custody_entry(
10898 key,
10899 next_seq,
10900 ts,
10901 &sha256,
10902 &manifest,
10903 remote.head.feed_hash.as_deref(),
10904 )?;
10905 meta["entry"] = Value::String(entry);
10906 }
10907 let presigned = ensure_ok(
10908 request(
10909 cfg,
10910 "POST",
10911 &format!("/api/hub/brains/{brain}/packs/presign"),
10912 Some(&meta),
10913 Auth::Required,
10914 )?,
10915 "prepare pack upload",
10916 )?;
10917 let url = presigned
10918 .get("url")
10919 .and_then(Value::as_str)
10920 .ok_or_else(|| LinkError::InvalidPack {
10921 message: "the hub returned no upload URL".to_string(),
10922 })?;
10923 put_presigned(
10924 cfg,
10925 url,
10926 presigned.get("headers").unwrap_or(&Value::Null),
10927 &pack,
10928 )?;
10929 let committed = ensure_ok(
10930 request(
10931 cfg,
10932 "POST",
10933 &format!("/api/hub/brains/{brain}/packs/commit"),
10934 Some(&meta),
10935 Auth::Required,
10936 )?,
10937 "commit pack",
10938 )?;
10939 Ok(committed)
10940}
10941
10942fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
10943 const LOCAL_HEADER: u32 = 0x0403_4b50;
10944 const CENTRAL_HEADER: u32 = 0x0201_4b50;
10945 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
10946 const VERSION_20: u16 = 20;
10947 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
10948 const UTF8_FLAG: u16 = 1 << 11;
10949 const STORED: u16 = 0;
10950 const DOS_TIME_MIDNIGHT: u16 = 0;
10951 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
10952 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
10953
10954 struct CentralEntry<'a> {
10955 name: &'a [u8],
10956 crc32: u32,
10957 size: u32,
10958 local_offset: u32,
10959 }
10960
10961 fn push_u16(out: &mut Vec<u8>, value: u16) {
10962 out.extend_from_slice(&value.to_le_bytes());
10963 }
10964
10965 fn push_u32(out: &mut Vec<u8>, value: u32) {
10966 out.extend_from_slice(&value.to_le_bytes());
10967 }
10968
10969 if files.is_empty() {
10970 return Err(LinkError::InvalidPack {
10971 message: "cannot create an empty snapshot pack".to_string(),
10972 });
10973 }
10974 if files.len() > u16::MAX as usize {
10975 return Err(LinkError::PushTooLarge {
10976 detail: format!(
10977 "{} files (canonical ZIP32 packs cap at {})",
10978 files.len(),
10979 u16::MAX
10980 ),
10981 });
10982 }
10983
10984 let mut sorted: Vec<_> = files.iter().collect();
10985 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
10986 let mut previous: Option<&str> = None;
10987 for (path, content) in &sorted {
10988 if !safe_store_rel_path(path) {
10989 return Err(LinkError::UnsafePath {
10990 path: (*path).clone(),
10991 });
10992 }
10993 if previous == Some(path.as_str()) {
10994 return Err(LinkError::InvalidPack {
10995 message: format!("duplicate path `{path}`"),
10996 });
10997 }
10998 previous = Some(path.as_str());
10999 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
11000 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11001 })?;
11002 }
11003
11004 let mut out = Vec::new();
11005 let mut central = Vec::with_capacity(sorted.len());
11006 for (path, content) in sorted {
11007 let name = path.as_bytes();
11008 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
11009 message: format!("ZIP entry name is too long: `{path}`"),
11010 })?;
11011 let bytes = content.as_bytes();
11012 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
11013 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11014 })?;
11015 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11016 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11017 })?;
11018 let crc32 = crc32fast::hash(bytes);
11019
11020 push_u32(&mut out, LOCAL_HEADER);
11023 push_u16(&mut out, VERSION_20);
11024 push_u16(&mut out, UTF8_FLAG);
11025 push_u16(&mut out, STORED);
11026 push_u16(&mut out, DOS_TIME_MIDNIGHT);
11027 push_u16(&mut out, DOS_DATE_1980_01_01);
11028 push_u32(&mut out, crc32);
11029 push_u32(&mut out, size);
11030 push_u32(&mut out, size);
11031 push_u16(&mut out, name_len);
11032 push_u16(&mut out, 0); out.extend_from_slice(name);
11034 out.extend_from_slice(bytes);
11035
11036 central.push(CentralEntry {
11037 name,
11038 crc32,
11039 size,
11040 local_offset,
11041 });
11042 }
11043
11044 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11045 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11046 })?;
11047 for entry in ¢ral {
11048 push_u32(&mut out, CENTRAL_HEADER);
11049 push_u16(&mut out, MADE_BY_UNIX_20);
11050 push_u16(&mut out, VERSION_20);
11051 push_u16(&mut out, UTF8_FLAG);
11052 push_u16(&mut out, STORED);
11053 push_u16(&mut out, DOS_TIME_MIDNIGHT);
11054 push_u16(&mut out, DOS_DATE_1980_01_01);
11055 push_u32(&mut out, entry.crc32);
11056 push_u32(&mut out, entry.size);
11057 push_u32(&mut out, entry.size);
11058 push_u16(&mut out, entry.name.len() as u16);
11059 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);
11064 push_u32(&mut out, entry.local_offset);
11065 out.extend_from_slice(entry.name);
11066 }
11067 let central_size = u32::try_from(out.len())
11068 .ok()
11069 .and_then(|end| end.checked_sub(central_offset))
11070 .ok_or_else(|| LinkError::PushTooLarge {
11071 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
11072 })?;
11073 let entry_count = central.len() as u16;
11074
11075 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
11076 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
11079 push_u16(&mut out, entry_count);
11080 push_u32(&mut out, central_size);
11081 push_u32(&mut out, central_offset);
11082 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
11085 return Err(LinkError::PushTooLarge {
11086 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
11087 });
11088 }
11089 Ok(out)
11090}
11091
11092#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11098pub enum Capability {
11099 Read,
11101 Write,
11103}
11104
11105impl Capability {
11106 pub fn as_str(self) -> &'static str {
11108 match self {
11109 Capability::Read => "read",
11110 Capability::Write => "write",
11111 }
11112 }
11113}
11114
11115pub fn grant_issue(
11121 cfg: &HubConfig,
11122 brain: &str,
11123 grantee: &str,
11124 can: Capability,
11125 scope: Option<&str>,
11126 until: Option<&str>,
11127) -> LinkResult<Value> {
11128 require_safe_ref(brain)?;
11129 let is_key_grantee = URL_SAFE_NO_PAD
11134 .decode(grantee)
11135 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
11136 .unwrap_or(false);
11137 if let Some(head) = v2_verified_head(cfg, brain)? {
11138 if is_key_grantee {
11139 let scope = scope.unwrap_or("");
11140 let preset = match can {
11141 Capability::Read => "viewer",
11142 Capability::Write => "editor",
11143 };
11144 let entropy = format!(
11145 "{}\0{}\0{}\0{}\0{}\0{}\0{}",
11146 normalized_origin(&cfg.hub)?,
11147 head.brain_id,
11148 head.control_revision,
11149 grantee,
11150 preset,
11151 scope,
11152 until.unwrap_or("")
11153 );
11154 let mut body = json!({
11155 "context": "external",
11156 "expected_control_revision": head.control_revision,
11157 "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
11158 "preset": preset,
11159 "principal_kind": "key",
11160 "public_key": grantee,
11161 "scope": scope,
11162 "scope_kind": "prefix",
11163 });
11164 if let Some(value) = until {
11165 body["expires_at"] = json!(value);
11166 }
11167 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11168 let response = ensure_ok(
11169 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11170 "v2 grant issue",
11171 )?;
11172 let expected_fingerprint = identity_fingerprint(grantee)?;
11173 if response.get("v").and_then(Value::as_u64) != Some(2)
11174 || response
11175 .get("id")
11176 .and_then(Value::as_str)
11177 .is_none_or(|id| !crate::ulid::is_ulid(id))
11178 || response.get("principal_kind").and_then(Value::as_str) != Some("key")
11179 || response.get("principal_id").and_then(Value::as_str)
11180 != Some(expected_fingerprint.as_str())
11181 || response
11182 .get("control_revision")
11183 .and_then(Value::as_str)
11184 .is_none_or(|value| !is_sha256(value))
11185 {
11186 return Err(invalid_feed(
11187 "v2 grant issue response is not authority-bound",
11188 ));
11189 }
11190 return Ok(response);
11191 }
11192 let mut body = json!({ "email": grantee, "capability": can.as_str() });
11198 if let Some(value) = scope {
11199 body["scopePrefix"] = json!(value);
11200 }
11201 if let Some(value) = until {
11202 body["expiresAt"] = json!(value);
11203 }
11204 let path = format!("/api/hub/brains/{}/grants", head.brain_id);
11205 return ensure_ok(
11206 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11207 "account grant issue",
11208 );
11209 }
11210 let _ = verified_remote_head(cfg, brain, false)?;
11211 let mut body = if is_key_grantee {
11212 json!({ "keySpki": grantee, "capability": can.as_str() })
11213 } else {
11214 json!({ "email": grantee, "capability": can.as_str() })
11215 };
11216 if let Some(s) = scope {
11217 body["scopePrefix"] = json!(s);
11218 }
11219 if let Some(u) = until {
11220 body["expiresAt"] = json!(u);
11221 }
11222 let path = format!("/api/hub/brains/{brain}/grants");
11223 ensure_ok(
11224 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11225 "grant issue",
11226 )
11227}
11228
11229pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
11231 require_safe_ref(brain)?;
11232 if let Some(head) = v2_verified_head(cfg, brain)? {
11233 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11234 let response = ensure_ok(
11235 request(cfg, "GET", &path, None, Auth::Required)?,
11236 "v2 grant list",
11237 )?;
11238 if response.get("v").and_then(Value::as_u64) != Some(2)
11239 || response.get("control_revision").and_then(Value::as_str)
11240 != Some(head.control_revision.as_str())
11241 || !response.get("grants").is_some_and(Value::is_array)
11242 {
11243 return Err(invalid_feed(
11244 "v2 grant list is not bound to the verified authority",
11245 ));
11246 }
11247 return Ok(response);
11248 }
11249 let _ = verified_remote_head(cfg, brain, false)?;
11250 let path = format!("/api/hub/brains/{brain}/grants");
11251 ensure_ok(
11252 request(cfg, "GET", &path, None, Auth::Required)?,
11253 "grant list",
11254 )
11255}
11256
11257pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
11260 require_safe_ref(brain)?;
11261 require_safe_grant_id(grant_id)?;
11262 if let Some(head) = v2_verified_head(cfg, brain)? {
11263 let entropy = format!(
11264 "{}\0{}\0{}\0{}",
11265 normalized_origin(&cfg.hub)?,
11266 head.brain_id,
11267 head.control_revision,
11268 grant_id
11269 );
11270 let body = json!({
11271 "expected_control_revision": head.control_revision,
11272 "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
11273 });
11274 let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
11275 let response = ensure_ok(
11276 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11277 "v2 grant revoke",
11278 )?;
11279 if response.get("v").and_then(Value::as_u64) != Some(2)
11280 || response.get("id").and_then(Value::as_str) != Some(grant_id)
11281 || response.get("revoked").and_then(Value::as_bool) != Some(true)
11282 || response
11283 .get("control_revision")
11284 .and_then(Value::as_str)
11285 .is_none_or(|value| !is_sha256(value))
11286 {
11287 return Err(invalid_feed(
11288 "v2 grant revocation response is not authority-bound",
11289 ));
11290 }
11291 return Ok(response);
11292 }
11293 let _ = verified_remote_head(cfg, brain, false)?;
11294 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
11295 ensure_ok(
11296 request(cfg, "DELETE", &path, None, Auth::Required)?,
11297 "grant revoke",
11298 )
11299}
11300
11301#[derive(Debug)]
11306struct VerifiedV2Proposal {
11307 value: Value,
11308 changes: Value,
11309 blobs: Vec<(String, u64, String)>,
11310}
11311
11312fn require_proposal_id(id: &str) -> LinkResult<()> {
11313 if crate::ulid::is_ulid(id) {
11314 Ok(())
11315 } else {
11316 Err(invalid_feed("proposal id is not a lowercase ULID"))
11317 }
11318}
11319
11320fn verified_v2_proposal(
11321 cfg: &HubConfig,
11322 head: &V2VerifiedHead,
11323 proposal_id: &str,
11324) -> LinkResult<VerifiedV2Proposal> {
11325 require_proposal_id(proposal_id)?;
11326 if head.view_kind != "full" {
11327 return Err(invalid_feed(
11328 "proposal review requires a full readable view",
11329 ));
11330 }
11331 let path = format!(
11332 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11333 head.brain_id
11334 );
11335 let value = ensure_ok(
11336 request_capped(
11337 cfg,
11338 "GET",
11339 &path,
11340 None,
11341 Auth::Required,
11342 MAX_FEED_RESPONSE_BYTES,
11343 )?,
11344 "v2 proposal",
11345 )?;
11346 verify_v2_proposal_value(head, proposal_id, value)
11347}
11348
11349fn verify_v2_proposal_value(
11350 head: &V2VerifiedHead,
11351 proposal_id: &str,
11352 value: Value,
11353) -> LinkResult<VerifiedV2Proposal> {
11354 if value.get("v").and_then(Value::as_u64) != Some(2) {
11355 return Err(invalid_feed("proposal response has an invalid version"));
11356 }
11357 let proposal = value
11358 .get("proposal")
11359 .and_then(Value::as_object)
11360 .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
11361 if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
11362 return Err(invalid_feed("proposal response changed its id"));
11363 }
11364 let payload_hash = proposal
11365 .get("payload_sha256")
11366 .and_then(Value::as_str)
11367 .filter(|hash| is_sha256(hash))
11368 .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
11369 let clear_hash = proposal
11370 .get("clear_sha256")
11371 .and_then(Value::as_str)
11372 .filter(|hash| is_sha256(hash))
11373 .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
11374 let submission_hash = proposal
11375 .get("submission_claim_sha256")
11376 .and_then(Value::as_str)
11377 .filter(|hash| is_sha256(hash))
11378 .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
11379 let submission = STANDARD
11380 .decode(
11381 proposal
11382 .get("submission_claim_base64")
11383 .and_then(Value::as_str)
11384 .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
11385 )
11386 .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
11387 let submission_value: Value = serde_json::from_slice(&submission)
11388 .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
11389 if crate::linkmd_v2::canonical_bytes(&submission_value)
11390 .map_err(|error| invalid_feed(error.to_string()))?
11391 != submission
11392 || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
11393 .map_err(|error| invalid_feed(error.to_string()))?
11394 != submission_hash
11395 {
11396 return Err(invalid_feed(
11397 "proposal submission claim is not canonical or addressed",
11398 ));
11399 }
11400 let envelope = submission_value
11401 .as_object()
11402 .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
11403 let claim = envelope
11404 .get("claim")
11405 .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
11406 let claim_object = claim
11407 .as_object()
11408 .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
11409 let actor_root = claim_object
11410 .get("actor_root")
11411 .and_then(Value::as_object)
11412 .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
11413 let public_key = envelope
11414 .get("public_key")
11415 .and_then(Value::as_str)
11416 .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
11417 let fingerprint = envelope
11418 .get("fingerprint")
11419 .and_then(Value::as_str)
11420 .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
11421 let signature = envelope
11422 .get("sig")
11423 .and_then(Value::as_str)
11424 .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
11425 let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
11426 .map_err(|error| invalid_feed(error.to_string()))?;
11427 let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
11428 let signer = format!("{fingerprint}:{public_key}");
11429 let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
11430 let grants = actor_root.get("grants").and_then(Value::as_array);
11431 let grants_are_canonical = grants.is_some_and(|items| {
11432 let mut prior: Option<&str> = None;
11433 items.iter().all(|item| {
11434 let Some(grant) = item.as_str() else {
11435 return false;
11436 };
11437 if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
11438 return false;
11439 }
11440 prior = Some(grant);
11441 true
11442 })
11443 });
11444 let optional_actor_field = |name: &str| {
11445 actor_root.get(name).is_some_and(|value| {
11446 value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
11447 })
11448 };
11449 let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
11450 if claim_object.get("v").and_then(Value::as_u64) != Some(2)
11451 || format!("{:x}", Sha256::digest(&der)) != fingerprint
11452 || head
11453 .trust
11454 .hub_signer
11455 .as_ref()
11456 .is_some_and(|known| known != &signer)
11457 || !matches!(
11458 actor_class,
11459 Some(
11460 "user"
11461 | "owned_agent"
11462 | "foreign_key"
11463 | "curation"
11464 | "inbox"
11465 | "restore"
11466 | "migration"
11467 | "operator_recovery"
11468 )
11469 )
11470 || actor_root
11471 .get("principal")
11472 .and_then(Value::as_str)
11473 .is_none_or(|value| value.is_empty())
11474 || actor_root
11475 .get("credential")
11476 .and_then(Value::as_str)
11477 .is_none_or(|value| value.is_empty())
11478 || !optional_actor_field("organization")
11479 || !optional_actor_field("role")
11480 || !grants_are_canonical
11481 || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
11482 || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11483 || !claim_object
11484 .get("mutation_id")
11485 .and_then(Value::as_str)
11486 .is_some_and(|value| {
11487 !value.is_empty()
11488 && value.len() <= 128
11489 && value.chars().enumerate().all(|(index, char)| {
11490 char.is_ascii_alphanumeric()
11491 || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
11492 })
11493 })
11494 || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
11495 || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
11496 || !claim_object
11497 .get("control_revision")
11498 .and_then(Value::as_str)
11499 .is_some_and(is_sha256)
11500 || submitted_at.is_none_or(|value| {
11501 chrono::DateTime::parse_from_rfc3339(value).is_err()
11502 || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
11503 })
11504 || !proposal
11505 .get("state")
11506 .and_then(Value::as_str)
11507 .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
11508 || proposal
11509 .get("expires_at")
11510 .and_then(Value::as_str)
11511 .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
11512 || proposal
11513 .get("proposer")
11514 .and_then(Value::as_object)
11515 .and_then(|value| value.get("class"))
11516 .and_then(Value::as_str)
11517 != actor_class
11518 {
11519 return Err(invalid_feed(
11520 "proposal submission claim does not bind the verified proposal",
11521 ));
11522 }
11523 let changes_b64 = proposal
11524 .get("changes_base64")
11525 .and_then(Value::as_str)
11526 .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
11527 let changes_bytes = STANDARD
11528 .decode(changes_b64)
11529 .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
11530 let changes: Value = serde_json::from_slice(&changes_bytes)
11531 .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
11532 if crate::linkmd_v2::canonical_bytes(&changes)
11533 .map_err(|error| invalid_feed(error.to_string()))?
11534 != changes_bytes
11535 || changes.get("v").and_then(Value::as_u64) != Some(2)
11536 || !changes.get("operations").is_some_and(Value::is_array)
11537 {
11538 return Err(invalid_feed("proposal changeset is not canonical v2"));
11539 }
11540 let blob_values = proposal
11541 .get("blobs")
11542 .and_then(Value::as_array)
11543 .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
11544 let mut blobs = Vec::with_capacity(blob_values.len());
11545 let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
11546 let mut prior_hash: Option<String> = None;
11547 for item in blob_values {
11548 let hash = item
11549 .get("sha256")
11550 .and_then(Value::as_str)
11551 .filter(|hash| is_sha256(hash))
11552 .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
11553 let bytes = item
11554 .get("bytes")
11555 .and_then(Value::as_u64)
11556 .filter(|bytes| *bytes <= MAX_STORE_BYTES)
11557 .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
11558 if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
11559 return Err(invalid_feed(
11560 "proposal blob declarations are not unique and sorted",
11561 ));
11562 }
11563 prior_hash = Some(hash.to_string());
11564 let endpoint = item
11565 .get("endpoint")
11566 .and_then(Value::as_str)
11567 .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
11568 let expected_endpoint = format!(
11569 "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
11570 head.brain_id
11571 );
11572 if endpoint != expected_endpoint {
11573 return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
11574 }
11575 descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
11576 blobs.push((hash.to_string(), bytes, endpoint.to_string()));
11577 }
11578 let descriptor = json!({
11579 "base": proposal.get("base").cloned().unwrap_or(Value::Null),
11580 "blobs": descriptor_blobs,
11581 "changes_base64": changes_b64,
11582 "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
11583 "v": 2,
11584 });
11585 let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
11586 .map_err(|error| invalid_feed(error.to_string()))?;
11587 if content_sha256(&descriptor_bytes) != clear_hash {
11588 return Err(invalid_feed(
11589 "proposal clear payload differs from its signed submission claim",
11590 ));
11591 }
11592 Ok(VerifiedV2Proposal {
11593 value,
11594 changes,
11595 blobs,
11596 })
11597}
11598
11599pub fn proposal_list(
11600 cfg: &HubConfig,
11601 brain: &str,
11602 state: &str,
11603 after: Option<&str>,
11604 limit: usize,
11605) -> LinkResult<Value> {
11606 require_safe_ref(brain)?;
11607 if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11608 return Err(invalid_feed("proposal state is invalid"));
11609 }
11610 if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11611 return Err(invalid_feed("proposal cursor is invalid"));
11612 }
11613 let head = v2_verified_head(cfg, brain)?
11614 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11615 let path = format!(
11616 "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11617 head.brain_id,
11618 limit.clamp(1, 100),
11619 after.map_or_else(String::new, |value| format!("&after={value}"))
11620 );
11621 ensure_ok(
11622 request_capped(
11623 cfg,
11624 "GET",
11625 &path,
11626 None,
11627 Auth::Required,
11628 MAX_FEED_RESPONSE_BYTES,
11629 )?,
11630 "v2 proposal list",
11631 )
11632}
11633
11634pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11635 require_safe_ref(brain)?;
11636 let head = v2_verified_head(cfg, brain)?
11637 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11638 Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11639}
11640
11641pub fn proposal_reject(
11642 cfg: &HubConfig,
11643 brain: &str,
11644 proposal_id: &str,
11645 mutation_id: &str,
11646 reason: &str,
11647) -> LinkResult<Value> {
11648 require_safe_ref(brain)?;
11649 require_proposal_id(proposal_id)?;
11650 let head = v2_verified_head(cfg, brain)?
11651 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11652 let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
11653 let body = json!({
11654 "mutation_id": mutation_id,
11655 "control_revision": head.control_revision,
11656 "reason": reason,
11657 });
11658 let path = format!(
11659 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11660 head.brain_id
11661 );
11662 ensure_ok(
11663 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11664 "v2 proposal rejection",
11665 )
11666}
11667
11668pub fn proposal_accept_exact(
11669 cfg: &HubConfig,
11670 brain: &str,
11671 proposal_id: &str,
11672 mutation_id: &str,
11673 reason: &str,
11674) -> LinkResult<Value> {
11675 require_safe_ref(brain)?;
11676 require_proposal_id(proposal_id)?;
11677 let head = v2_verified_head(cfg, brain)?
11678 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11679 let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
11680 let operations = proposal
11681 .changes
11682 .get("operations")
11683 .and_then(Value::as_array)
11684 .cloned()
11685 .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
11686 if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
11687 return Err(invalid_feed("proposal operation count is invalid"));
11688 }
11689 let mut downloaded = std::collections::BTreeMap::new();
11690 for (hash, bytes, endpoint) in &proposal.blobs {
11691 let body = ensure_raw_ok(
11692 request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
11693 "v2 proposal blob",
11694 )?;
11695 if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
11696 return Err(invalid_feed("proposal blob does not match its declaration"));
11697 }
11698 downloaded.insert(hash.clone(), body);
11699 }
11700 let remote = files_for_v2_view(
11701 &head,
11702 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
11703 );
11704 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
11705 let mut expected_candidate = remote.clone();
11706 let mut expected_candidate_assets = remote_assets;
11707 for operation in &operations {
11708 let op = operation
11709 .get("op")
11710 .and_then(Value::as_str)
11711 .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
11712 match op {
11713 "put" | "restore" => {
11714 let path = operation
11715 .get("path")
11716 .and_then(Value::as_str)
11717 .ok_or_else(|| invalid_feed("proposal write has no path"))?;
11718 crate::linkmd_v2::normalize_path(path)
11719 .map_err(|error| invalid_feed(error.to_string()))?;
11720 let hash = operation
11721 .get("blob")
11722 .and_then(Value::as_str)
11723 .filter(|hash| is_sha256(hash))
11724 .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
11725 let bytes = operation
11726 .get("bytes")
11727 .and_then(Value::as_u64)
11728 .ok_or_else(|| invalid_feed("proposal write has no size"))?;
11729 expected_candidate.insert(
11730 path.to_string(),
11731 V2BaselineFile {
11732 sha256: hash.to_string(),
11733 bytes,
11734 proof: None,
11735 },
11736 );
11737 }
11738 "delete" | "withdraw_from_hosting" => {
11739 let path = operation
11740 .get("path")
11741 .and_then(Value::as_str)
11742 .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
11743 crate::linkmd_v2::normalize_path(path)
11744 .map_err(|error| invalid_feed(error.to_string()))?;
11745 expected_candidate.remove(path);
11746 }
11747 "rename" => {
11748 let from = operation
11749 .get("from")
11750 .and_then(Value::as_str)
11751 .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
11752 let to = operation
11753 .get("to")
11754 .and_then(Value::as_str)
11755 .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
11756 crate::linkmd_v2::normalize_path(from)
11757 .and_then(|_| crate::linkmd_v2::normalize_path(to))
11758 .map_err(|error| invalid_feed(error.to_string()))?;
11759 let hash = operation
11760 .get("blob")
11761 .and_then(Value::as_str)
11762 .filter(|hash| is_sha256(hash))
11763 .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
11764 let bytes = operation
11765 .get("bytes")
11766 .and_then(Value::as_u64)
11767 .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
11768 expected_candidate.remove(from);
11769 expected_candidate.insert(
11770 to.to_string(),
11771 V2BaselineFile {
11772 sha256: hash.to_string(),
11773 bytes,
11774 proof: None,
11775 },
11776 );
11777 }
11778 "asset_delete" => {
11779 let path = operation
11780 .get("path")
11781 .and_then(Value::as_str)
11782 .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
11783 expected_candidate_assets.remove(path);
11784 }
11785 "asset_withdraw" => {
11786 let path = operation
11787 .get("path")
11788 .and_then(Value::as_str)
11789 .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
11790 let asset = expected_candidate_assets
11791 .get_mut(path)
11792 .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
11793 asset.disposition = "withheld".to_string();
11794 asset.leaf_hash.clear();
11795 }
11796 "asset_put" | "asset_resume" => {
11797 let path = operation
11798 .get("path")
11799 .and_then(Value::as_str)
11800 .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
11801 let asset = operation
11802 .get("asset")
11803 .and_then(Value::as_object)
11804 .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
11805 let blob_sha256 = asset
11806 .get("blob_sha256")
11807 .and_then(Value::as_str)
11808 .filter(|hash| is_sha256(hash))
11809 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
11810 let bytes = asset
11811 .get("bytes")
11812 .and_then(Value::as_u64)
11813 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
11814 let media_type = asset
11815 .get("media_type")
11816 .and_then(Value::as_str)
11817 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
11818 let wrappers = asset
11819 .get("wrappers")
11820 .and_then(Value::as_array)
11821 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
11822 .iter()
11823 .map(|wrapper| {
11824 wrapper
11825 .as_str()
11826 .map(str::to_string)
11827 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
11828 })
11829 .collect::<LinkResult<Vec<_>>>()?;
11830 let required = asset
11831 .get("required")
11832 .and_then(Value::as_bool)
11833 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
11834 let disposition = asset
11835 .get("disposition")
11836 .and_then(Value::as_str)
11837 .filter(|value| matches!(*value, "hosted" | "withheld"))
11838 .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
11839 expected_candidate_assets.insert(
11840 path.to_string(),
11841 V2BaselineAsset {
11842 blob_sha256: blob_sha256.to_string(),
11843 bytes,
11844 media_type: media_type.to_string(),
11845 wrappers,
11846 required,
11847 disposition: disposition.to_string(),
11848 leaf_hash: String::new(),
11849 },
11850 );
11851 }
11852 _ => return Err(invalid_feed("proposal operation kind is unsupported")),
11853 }
11854 }
11855 let base = head.pointer.as_ref().map(|pointer| {
11856 json!({
11857 "seq": pointer.seq,
11858 "commit_hash": pointer.commit_hash,
11859 "content_root": pointer.content_root,
11860 "asset_root": pointer.asset_root,
11861 })
11862 });
11863 let mut body = json!({
11864 "mutation_id": mutation_id,
11865 "base": base,
11866 "rebase": "strict",
11867 "reason": reason,
11868 "operations": operations,
11869 "blobs": downloaded
11870 .iter()
11871 .map(|(sha256, bytes)| json!({
11872 "sha256": sha256,
11873 "bytes": bytes.len(),
11874 "content_base64": STANDARD.encode(bytes),
11875 }))
11876 .collect::<Vec<_>>(),
11877 "proposal_id": proposal_id,
11878 "proposal_mode": "exact",
11879 });
11880 let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
11881 total
11882 .checked_add(bytes.len())
11883 .ok_or_else(|| LinkError::PushTooLarge {
11884 detail: "proposal changed-byte total overflow".to_string(),
11885 })
11886 })?;
11887 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
11888 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11889 for operation in &operations {
11890 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
11891 return Err(invalid_feed("proposal upload operation has no kind"));
11892 };
11893 let hash = match kind {
11894 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
11895 "asset_put" | "asset_resume" => operation
11896 .get("asset")
11897 .and_then(|asset| asset.get("blob_sha256"))
11898 .and_then(Value::as_str),
11899 _ => None,
11900 };
11901 let Some(hash) = hash else { continue };
11902 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
11903 if kind == "rename" {
11904 for field in ["from", "to"] {
11905 coordinates.insert(
11906 operation
11907 .get(field)
11908 .and_then(Value::as_str)
11909 .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
11910 .to_string(),
11911 );
11912 }
11913 } else {
11914 let path = operation
11915 .get("path")
11916 .and_then(Value::as_str)
11917 .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
11918 coordinates.insert(if kind.starts_with("asset_") {
11919 format!("assets/{path}")
11920 } else {
11921 path.to_string()
11922 });
11923 }
11924 }
11925 let declarations = downloaded
11926 .iter()
11927 .map(|(sha256, bytes)| {
11928 json!({
11929 "sha256": sha256,
11930 "bytes": bytes.len(),
11931 "coordinates": coordinates_by_hash
11932 .get(sha256)
11933 .into_iter()
11934 .flatten()
11935 .collect::<Vec<_>>(),
11936 })
11937 })
11938 .collect::<Vec<_>>();
11939 let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
11940 for batch in batch_upload_declarations(declarations) {
11941 let reserved = reserve_upload_window(
11942 cfg,
11943 &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
11944 &json!({ "blobs": batch }),
11945 "prepare proposal blob transport",
11946 )?;
11947 let reserved_items = reserved
11948 .get("uploads")
11949 .and_then(Value::as_array)
11950 .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
11951 items.extend(reserved_items.iter().cloned());
11952 }
11953 if items.len() != downloaded.len() {
11954 return Err(invalid_feed("proposal upload reservation changed the set"));
11955 }
11956 let mut references = Vec::with_capacity(items.len());
11957 for item in items {
11958 let hash = item
11959 .get("sha256")
11960 .and_then(Value::as_str)
11961 .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
11962 let bytes = downloaded
11963 .get(hash)
11964 .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
11965 let reservation_id = item
11966 .get("reservation_id")
11967 .and_then(Value::as_str)
11968 .filter(|id| crate::ulid::is_ulid(id))
11969 .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
11970 let expected_coordinates = coordinates_by_hash
11971 .get(hash)
11972 .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
11973 let returned_coordinates = item
11974 .get("coordinates")
11975 .and_then(Value::as_array)
11976 .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
11977 if returned_coordinates.len() != expected_coordinates.len()
11978 || returned_coordinates
11979 .iter()
11980 .zip(expected_coordinates)
11981 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
11982 {
11983 return Err(invalid_feed(
11984 "proposal upload reservation changed its coordinates",
11985 ));
11986 }
11987 match item.get("status").and_then(Value::as_str) {
11988 Some("upload") => put_presigned(
11989 cfg,
11990 item.get("url")
11991 .and_then(Value::as_str)
11992 .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
11993 item.get("headers").unwrap_or(&Value::Null),
11994 bytes,
11995 )?,
11996 Some("already_present") => {}
11997 _ => return Err(invalid_feed("proposal upload status is invalid")),
11998 }
11999 references.push(json!({
12000 "sha256": hash,
12001 "bytes": bytes.len(),
12002 "reservation_id": reservation_id,
12003 }));
12004 }
12005 body["blobs"] = Value::Array(references);
12006 }
12007 stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
12011 let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
12012 let mut result = ensure_ok(
12013 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
12014 "exact proposal acceptance",
12015 )?;
12016 let mut candidate_hub_signer = None;
12017 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
12018 let request_id = result
12019 .get("request_id")
12020 .and_then(Value::as_str)
12021 .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
12022 .to_string();
12023 let challenge = result
12024 .get("signing_challenge")
12025 .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
12026 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
12027 cfg,
12028 &head,
12029 &expected_candidate,
12030 &expected_candidate_assets,
12031 mutation_id,
12032 &v2_signed_request_view(&body, &operations),
12033 challenge,
12034 )?;
12035 body["signing_challenge_id"] = Value::String(challenge_id);
12036 body["signature_base64url"] = Value::String(signature);
12037 candidate_hub_signer = Some(actor_signer);
12038 result = ensure_ok(
12039 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
12040 "signed exact proposal acceptance",
12041 )?;
12042 }
12043 let refreshed = v2_verified_head(cfg, brain)?
12044 .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
12045 if candidate_hub_signer
12046 .as_ref()
12047 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
12048 || refreshed
12049 .pointer
12050 .as_ref()
12051 .map(|pointer| pointer.commit_hash.as_str())
12052 != result.get("commit_hash").and_then(Value::as_str)
12053 || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12054 || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
12055 {
12056 return Err(LinkError::RemoteAdvancedDuringSync);
12057 }
12058 accept_v2_head(cfg, &refreshed)?;
12059 Ok(result)
12060}
12061
12062pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
12073 require_valid_handle(handle)?;
12074 if body.len() as u64 > MAX_PROPOSE_BYTES {
12075 return Err(LinkError::ProposeTooLarge {
12076 bytes: body.len() as u64,
12077 });
12078 }
12079 let payload = json!({ "app": app, "body": body });
12080 let (path, auth) = if crate::ulid::is_ulid(handle) {
12085 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
12086 } else {
12087 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
12088 };
12089 ensure_ok(
12090 request(cfg, "POST", &path, Some(&payload), auth)?,
12091 "propose",
12092 )
12093}
12094
12095#[derive(Debug, serde::Serialize)]
12101pub struct Head {
12102 pub brain: String,
12104 pub seq: u64,
12106 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
12108 pub updated_at: Option<String>,
12109 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
12111 pub feed_hash: Option<String>,
12112 pub verified: bool,
12115}
12116
12117struct BoundedVecVisitor<T, const MAX: usize> {
12118 label: &'static str,
12119 marker: std::marker::PhantomData<T>,
12120}
12121
12122impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
12123where
12124 T: Deserialize<'de>,
12125{
12126 type Value = Vec<T>;
12127
12128 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12129 write!(formatter, "at most {MAX} {}", self.label)
12130 }
12131
12132 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
12133 where
12134 A: serde::de::SeqAccess<'de>,
12135 {
12136 if sequence.size_hint().is_some_and(|size| size > MAX) {
12137 return Err(serde::de::Error::custom(format!(
12138 "{} exceeds the {MAX}-item limit",
12139 self.label
12140 )));
12141 }
12142 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
12143 while let Some(value) = sequence.next_element()? {
12144 if values.len() == MAX {
12145 return Err(serde::de::Error::custom(format!(
12146 "{} exceeds the {MAX}-item limit",
12147 self.label
12148 )));
12149 }
12150 values.push(value);
12151 }
12152 Ok(values)
12153 }
12154}
12155
12156fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
12157 deserializer: D,
12158 label: &'static str,
12159) -> Result<Vec<T>, D::Error>
12160where
12161 D: serde::Deserializer<'de>,
12162 T: Deserialize<'de>,
12163{
12164 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
12165 label,
12166 marker: std::marker::PhantomData,
12167 })
12168}
12169
12170fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
12171where
12172 D: serde::Deserializer<'de>,
12173{
12174 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
12175}
12176
12177fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12178where
12179 D: serde::Deserializer<'de>,
12180{
12181 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
12182}
12183
12184fn deserialize_previous_identities<'de, D>(
12185 deserializer: D,
12186) -> Result<Vec<PreviousIdentity>, D::Error>
12187where
12188 D: serde::Deserializer<'de>,
12189{
12190 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
12191 deserializer,
12192 "previous identities",
12193 )
12194}
12195
12196fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12197where
12198 D: serde::Deserializer<'de>,
12199{
12200 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
12201 deserializer,
12202 "rotation statements",
12203 )
12204}
12205
12206fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
12207where
12208 D: serde::Deserializer<'de>,
12209{
12210 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
12211}
12212
12213#[derive(Debug, Clone, Deserialize, Serialize)]
12214struct FeedFile {
12215 path: String,
12216 sha256: String,
12217 bytes: u64,
12218}
12219
12220#[cfg(test)]
12221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12222enum V1DisclosureError {
12223 DuplicateFile,
12224 DuplicateRemoved,
12225 PushManifestMismatch,
12226 EditMissingChange,
12227 EditFalseFile,
12228 RemovedMismatch,
12229}
12230
12231#[cfg(test)]
12235fn verify_v1_manifest_disclosure(
12236 kind: &str,
12237 previous: &[FeedFile],
12238 resulting: &[FeedFile],
12239 files: &[FeedFile],
12240 removed: &[String],
12241) -> Result<(), V1DisclosureError> {
12242 fn as_map(
12243 files: &[FeedFile],
12244 ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
12245 let mut result = std::collections::BTreeMap::new();
12246 for file in files {
12247 if result
12248 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
12249 .is_some()
12250 {
12251 return Err(V1DisclosureError::DuplicateFile);
12252 }
12253 }
12254 Ok(result)
12255 }
12256 let previous = as_map(previous)?;
12257 let resulting = as_map(resulting)?;
12258 let disclosed = as_map(files)?;
12259 let removed_set: std::collections::BTreeSet<&str> =
12260 removed.iter().map(String::as_str).collect();
12261 if removed_set.len() != removed.len() {
12262 return Err(V1DisclosureError::DuplicateRemoved);
12263 }
12264 let expected_removed: std::collections::BTreeSet<&str> = previous
12265 .keys()
12266 .copied()
12267 .filter(|path| !resulting.contains_key(path))
12268 .collect();
12269 if removed_set != expected_removed {
12270 return Err(V1DisclosureError::RemovedMismatch);
12271 }
12272 if kind == "push" {
12273 return if disclosed == resulting {
12274 Ok(())
12275 } else {
12276 Err(V1DisclosureError::PushManifestMismatch)
12277 };
12278 }
12279 if kind != "edit" {
12280 return Err(V1DisclosureError::EditFalseFile);
12281 }
12282 if disclosed
12283 .iter()
12284 .any(|(path, value)| resulting.get(path) != Some(value))
12285 {
12286 return Err(V1DisclosureError::EditFalseFile);
12287 }
12288 for (path, value) in &resulting {
12289 if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
12290 return Err(V1DisclosureError::EditMissingChange);
12291 }
12292 }
12293 Ok(())
12294}
12295
12296#[derive(Debug, Clone, Deserialize, Serialize)]
12297struct FeedEntry {
12298 v: u8,
12299 seq: u64,
12300 ts: String,
12301 brain: String,
12302 public_key: String,
12303 kind: String,
12304 op: String,
12305 pack_sha256: String,
12306 #[serde(deserialize_with = "deserialize_feed_files")]
12307 files: Vec<FeedFile>,
12308 #[serde(deserialize_with = "deserialize_removed_paths")]
12309 removed: Vec<String>,
12310 prev_entry_hash: Option<String>,
12311 sig: String,
12312}
12313
12314#[derive(Serialize)]
12315struct UnsignedFeedEntry<'a> {
12316 v: u8,
12317 seq: u64,
12318 ts: &'a str,
12319 brain: &'a str,
12320 public_key: &'a str,
12321 kind: &'a str,
12322 op: &'a str,
12323 pack_sha256: &'a str,
12324 files: &'a [FeedFile],
12325 removed: &'a [String],
12326 prev_entry_hash: &'a Option<String>,
12327}
12328
12329#[derive(Debug, Clone, Deserialize, Serialize)]
12330struct FeedItem {
12331 hash: String,
12332 entry: FeedEntry,
12333}
12334
12335#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12336struct FeedIdentity {
12337 fingerprint: String,
12338 #[serde(rename = "publicKeySpki")]
12339 public_key_spki: String,
12340 #[serde(default, deserialize_with = "deserialize_previous_identities")]
12344 previous: Vec<PreviousIdentity>,
12345 #[serde(default, deserialize_with = "deserialize_rotations")]
12348 rotations: Vec<String>,
12349}
12350
12351#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12352struct PreviousIdentity {
12353 fingerprint: String,
12354 #[serde(rename = "publicKeySpki")]
12355 public_key_spki: String,
12356}
12357
12358#[derive(Debug, Deserialize)]
12359struct FeedResponse {
12360 #[serde(rename = "headSeq")]
12361 head_seq: u64,
12362 #[serde(rename = "feedHash")]
12363 feed_hash: Option<String>,
12364 identity: Option<FeedIdentity>,
12365 #[serde(deserialize_with = "deserialize_feed_items")]
12366 entries: Vec<FeedItem>,
12367 #[serde(rename = "scopeLimited")]
12368 scope_limited: bool,
12369}
12370
12371#[derive(Debug, Deserialize, Serialize)]
12372#[serde(deny_unknown_fields)]
12373struct RotationStatement {
12374 v: u8,
12375 op: String,
12376 brain: String,
12377 public_key: String,
12378 new_brain: String,
12379 new_public_key: String,
12380 prior_head_seq: u64,
12381 prior_feed_hash: Option<String>,
12382 ts: String,
12383 sig: String,
12384}
12385
12386#[derive(Debug, Clone, Deserialize, Serialize)]
12387struct TrustState {
12388 v: u8,
12389 origin: String,
12390 #[serde(default)]
12394 requested: String,
12395 brain: String,
12397 #[serde(default, skip_serializing_if = "Option::is_none")]
12400 home: Option<String>,
12401 anchor: String,
12402 current: String,
12403 #[serde(rename = "headSeq")]
12404 head_seq: u64,
12405 #[serde(rename = "feedHash")]
12406 feed_hash: Option<String>,
12407 #[serde(default)]
12411 rotations: Vec<String>,
12412 #[serde(default, skip_serializing_if = "Option::is_none")]
12415 hub_signer: Option<String>,
12416 #[serde(default, skip_serializing_if = "Option::is_none")]
12419 protocol_profile: Option<String>,
12420}
12421
12422fn accepted_as_v2(state: &TrustState) -> bool {
12423 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
12424}
12425
12426fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
12427 let directory = open_trust_dir(cfg)?;
12428 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
12429 return Ok(true);
12430 }
12431 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
12432 return Ok(false);
12433 };
12434 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
12435}
12436
12437#[derive(Debug, Clone, Deserialize, Serialize)]
12438struct AliasBinding {
12439 v: u8,
12440 origin: String,
12441 requested: String,
12442 brain: String,
12443 #[serde(default, skip_serializing_if = "Option::is_none")]
12444 home: Option<String>,
12445}
12446
12447struct VerifiedRemote {
12448 head: Head,
12449 identity: Option<FeedIdentity>,
12450 head_entry: Option<FeedItem>,
12451 entries: Vec<FeedItem>,
12453 anchor: Option<String>,
12454}
12455
12456fn invalid_feed(message: impl Into<String>) -> LinkError {
12457 LinkError::InvalidFeed {
12458 message: message.into(),
12459 }
12460}
12461
12462fn is_sha256(value: &str) -> bool {
12463 value.len() == 64
12464 && value
12465 .bytes()
12466 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
12467}
12468
12469fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
12470 let der = URL_SAFE_NO_PAD
12471 .decode(public_key_spki)
12472 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
12473 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
12474 return Err(invalid_feed(
12475 "identity public key is not a valid Ed25519 SPKI",
12476 ));
12477 }
12478 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
12479}
12480
12481fn verify_identity_chain(
12485 identity: &FeedIdentity,
12486 pinned: Option<&TrustState>,
12487) -> LinkResult<String> {
12488 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
12489 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
12490 {
12491 return Err(invalid_feed(
12492 "identity rotation history exceeds the client cap",
12493 ));
12494 }
12495 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
12496 return Err(invalid_feed(
12497 "current identity fingerprint does not match its public key",
12498 ));
12499 }
12500 for previous in &identity.previous {
12501 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
12502 return Err(invalid_feed(
12503 "previous identity fingerprint does not match its public key",
12504 ));
12505 }
12506 }
12507 if identity.rotations.len() != identity.previous.len() {
12508 return Err(invalid_feed(
12509 "identity history is missing an old-key-signed rotation statement",
12510 ));
12511 }
12512
12513 let mut chain: Vec<(&str, &str)> = identity
12517 .previous
12518 .iter()
12519 .rev()
12520 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
12521 .collect();
12522 chain.push((&identity.fingerprint, &identity.public_key_spki));
12523
12524 for (index, raw) in identity.rotations.iter().enumerate() {
12525 let statement: RotationStatement = serde_json::from_str(raw)
12526 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
12527 let (old_fingerprint, old_spki) = chain[index];
12528 let (new_fingerprint, new_spki) = chain[index + 1];
12529 if statement.v != 1
12530 || statement.op != "rotate"
12531 || statement.brain != format!("ed25519:{old_fingerprint}")
12532 || statement.public_key != old_spki
12533 || statement.new_brain != format!("ed25519:{new_fingerprint}")
12534 || statement.new_public_key != new_spki
12535 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
12536 || (statement.prior_head_seq > 0
12537 && statement
12538 .prior_feed_hash
12539 .as_deref()
12540 .is_none_or(|hash| !is_sha256(hash)))
12541 {
12542 return Err(invalid_feed(
12543 "rotation statement does not connect adjacent identities",
12544 ));
12545 }
12546 let unsigned = serde_json::to_string(&UnsignedRotation {
12547 v: statement.v,
12548 op: &statement.op,
12549 brain: &statement.brain,
12550 public_key: &statement.public_key,
12551 new_brain: &statement.new_brain,
12552 new_public_key: &statement.new_public_key,
12553 prior_head_seq: statement.prior_head_seq,
12554 prior_feed_hash: statement.prior_feed_hash.as_deref(),
12555 ts: statement.ts.clone(),
12556 })
12557 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
12558 let exact = format!(
12559 "{},\"sig\":\"{}\"}}",
12560 &unsigned[..unsigned.len() - 1],
12561 statement.sig
12562 );
12563 if exact != *raw {
12564 return Err(invalid_feed(
12565 "rotation statement is not in normative serialization",
12566 ));
12567 }
12568 let der = URL_SAFE_NO_PAD
12569 .decode(old_spki)
12570 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
12571 let signature = URL_SAFE_NO_PAD
12572 .decode(&statement.sig)
12573 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
12574 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
12575 .verify(unsigned.as_bytes(), &signature)
12576 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
12577 if index > 0 {
12578 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
12579 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
12580 if statement.prior_head_seq < prior.prior_head_seq {
12581 return Err(invalid_feed("rotation feed boundaries move backward"));
12582 }
12583 }
12584 }
12585
12586 let anchor = format!("ed25519:{}", chain[0].0);
12587 let current = format!("ed25519:{}", identity.fingerprint);
12588 if let Some(pin) = pinned {
12589 if pin.anchor != anchor {
12590 return Err(invalid_feed(
12591 "served identity chain does not descend from the pinned anchor",
12592 ));
12593 }
12594 if !chain
12595 .iter()
12596 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12597 {
12598 return Err(invalid_feed(
12599 "served identity chain forked away from the last pinned identity",
12600 ));
12601 }
12602 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
12603 return Err(invalid_feed("served identity discarded its rotation chain"));
12604 }
12605 if pin.v >= 2
12606 && (identity.rotations.len() < pin.rotations.len()
12607 || identity.rotations[..pin.rotations.len()] != pin.rotations)
12608 {
12609 return Err(invalid_feed(
12610 "served identity rewrote the locally accepted rotation history",
12611 ));
12612 }
12613 }
12614 Ok(anchor)
12615}
12616
12617fn verify_rotation_feed_boundaries(
12618 identity: &FeedIdentity,
12619 pinned: Option<&TrustState>,
12620 observed: &[FeedItem],
12621 advertised_seq: u64,
12622) -> LinkResult<()> {
12623 let mut chain: Vec<String> = identity
12624 .previous
12625 .iter()
12626 .rev()
12627 .map(|previous| format!("ed25519:{}", previous.fingerprint))
12628 .collect();
12629 chain.push(format!("ed25519:{}", identity.fingerprint));
12630 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
12631
12632 for (index, raw) in identity.rotations.iter().enumerate() {
12633 let rotation: RotationStatement = serde_json::from_str(raw)
12634 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12635 if rotation.prior_head_seq > advertised_seq {
12636 return Err(invalid_feed(
12637 "rotation claims a feed boundary beyond the advertised head",
12638 ));
12639 }
12640 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
12641 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
12642 return Err(invalid_feed(
12643 "newly disclosed rotation predates the local feed checkpoint",
12644 ));
12645 }
12646 }
12647 let actual = if rotation.prior_head_seq == 0 {
12648 None
12649 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
12650 pinned.and_then(|pin| pin.feed_hash.as_deref())
12651 } else {
12652 observed
12653 .iter()
12654 .find(|item| item.entry.seq == rotation.prior_head_seq)
12655 .map(|item| item.hash.as_str())
12656 };
12657 if let Some(actual) = actual {
12658 if rotation.prior_feed_hash.as_deref() != Some(actual) {
12659 return Err(invalid_feed(
12660 "rotation statement does not commit the verified feed boundary",
12661 ));
12662 }
12663 } else if rotation.prior_head_seq == 0 {
12664 } else if pinned.is_some_and(|pin| {
12667 pinned_index.is_some_and(|pin_index| index >= pin_index)
12668 || rotation.prior_head_seq >= pin.head_seq
12669 }) {
12670 return Err(invalid_feed(
12671 "rotation feed boundary was not present in the verified chain",
12672 ));
12673 }
12674 }
12675 Ok(())
12676}
12677
12678fn reject_retired_signer_after_checkpoint(
12683 identity: &FeedIdentity,
12684 pinned: Option<&TrustState>,
12685 item: &FeedItem,
12686) -> LinkResult<()> {
12687 let Some(pin) = pinned else {
12688 return Ok(());
12689 };
12690 if item.entry.seq <= pin.head_seq {
12691 return Ok(());
12692 }
12693 let mut chain: Vec<String> = identity
12694 .previous
12695 .iter()
12696 .rev()
12697 .map(|previous| format!("ed25519:{}", previous.fingerprint))
12698 .collect();
12699 chain.push(format!("ed25519:{}", identity.fingerprint));
12700 let pinned_index = chain
12701 .iter()
12702 .position(|key| key == &pin.current)
12703 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
12704 let signer_index = chain
12705 .iter()
12706 .position(|key| key == &item.entry.brain)
12707 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
12708 if signer_index < pinned_index {
12709 return Err(invalid_feed(
12710 "a retired identity attempted to sign after the local checkpoint",
12711 ));
12712 }
12713 Ok(())
12714}
12715
12716fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
12717 let origin = normalized_origin(&cfg.hub)?;
12718 let key = format!(
12719 "{:x}",
12720 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
12721 );
12722 Ok(format!("{key}.json"))
12723}
12724
12725fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
12726 let origin = normalized_origin(&cfg.hub)?;
12727 let key = format!(
12728 "{:x}",
12729 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
12730 );
12731 Ok(format!("alias-{key}.json"))
12732}
12733
12734#[cfg(any(unix, windows))]
12735struct TrustLock {
12736 _file: std::fs::File,
12737}
12738
12739#[cfg(unix)]
12740fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12741 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12742
12743 let lock_string = format!(".{state_name}.lock");
12744 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
12745 let fd = unsafe {
12746 libc::openat(
12747 directory.as_raw_fd(),
12748 lock_name.as_ptr(),
12749 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12750 0o600,
12751 )
12752 };
12753 if fd < 0 {
12754 return Err(std::io::Error::last_os_error().into());
12755 }
12756 let file = unsafe { std::fs::File::from_raw_fd(fd) };
12757 if !file.metadata()?.is_file() {
12758 return Err(LinkError::UnsafePath { path: lock_string });
12759 }
12760 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
12761 return Err(std::io::Error::last_os_error().into());
12762 }
12763 Ok(TrustLock { _file: file })
12764}
12765
12766#[cfg(windows)]
12767fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12768 let lock_name = format!(".{state_name}.lock");
12769 let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
12770 Ok(TrustLock { _file: file })
12771}
12772
12773#[cfg(any(unix, windows))]
12774fn lock_trust_many(
12775 cfg: &HubConfig,
12776 directory: &std::fs::File,
12777 refs: &[&str],
12778) -> LinkResult<Vec<TrustLock>> {
12779 let mut names = refs
12780 .iter()
12781 .map(|reference| trust_file_name(cfg, reference))
12782 .collect::<LinkResult<Vec<_>>>()?;
12783 names.sort();
12784 names.dedup();
12785 names
12786 .iter()
12787 .map(|name| lock_trust_name(directory, name))
12788 .collect()
12789}
12790
12791#[cfg(not(any(unix, windows)))]
12792fn lock_trust_many(
12793 _cfg: &HubConfig,
12794 _directory: &TrustDirectory,
12795 _refs: &[&str],
12796) -> LinkResult<Vec<()>> {
12797 Err(LinkError::UnsupportedPlatform {
12798 operation: "verified link.md state",
12799 })
12800}
12801
12802#[cfg(any(unix, windows))]
12803type TrustDirectory = std::fs::File;
12804
12805#[cfg(not(any(unix, windows)))]
12806struct TrustDirectory;
12807
12808#[cfg(unix)]
12809fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12810 use std::os::fd::AsRawFd as _;
12811
12812 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
12813 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
12814 return Err(std::io::Error::last_os_error().into());
12815 }
12816 directory.sync_all()?;
12817 Ok(directory)
12818}
12819
12820#[cfg(windows)]
12821fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12822 let marker = cfg.state_dir.join("trust").join(".directory");
12823 crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
12824 Ok(crate::fsx::open_directory_nofollow(
12825 marker.parent().expect("trust marker has a parent"),
12826 )?)
12827}
12828
12829#[cfg(not(any(unix, windows)))]
12830fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12831 Err(LinkError::UnsupportedPlatform {
12832 operation: "verified link.md state",
12833 })
12834}
12835
12836#[cfg(unix)]
12837fn load_trust_in(
12838 cfg: &HubConfig,
12839 directory: &TrustDirectory,
12840 requested: &str,
12841) -> LinkResult<Option<TrustState>> {
12842 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12843
12844 let name_string = trust_file_name(cfg, requested)?;
12845 let name = c_name(name_string.as_bytes(), &name_string)?;
12846 let fd = unsafe {
12847 libc::openat(
12848 directory.as_raw_fd(),
12849 name.as_ptr(),
12850 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12851 )
12852 };
12853 if fd < 0 {
12854 let error = std::io::Error::last_os_error();
12855 if error.kind() == std::io::ErrorKind::NotFound {
12856 return Ok(None);
12857 }
12858 return Err(LinkError::UnsafePath { path: name_string });
12859 }
12860 let file = unsafe { std::fs::File::from_raw_fd(fd) };
12861 if !file.metadata()?.is_file() {
12862 return Err(LinkError::UnsafePath { path: name_string });
12863 }
12864 let mut bytes = Vec::new();
12865 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
12866 if bytes.len() > 1024 * 1024 {
12867 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
12868 }
12869 let mut state: TrustState = serde_json::from_slice(&bytes)
12870 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
12871 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
12872 return Err(invalid_feed(
12873 "local identity/feed checkpoint does not match this hub and brain",
12874 ));
12875 }
12876 if state.v == 1 {
12877 if state.brain != requested {
12881 return Err(invalid_feed(
12882 "legacy checkpoint is not bound to the requested brain id",
12883 ));
12884 }
12885 state.requested = requested.to_string();
12886 } else if state.requested != requested {
12887 return Err(invalid_feed(
12888 "local identity/feed checkpoint is bound to a different requested ref",
12889 ));
12890 }
12891 Ok(Some(state))
12892}
12893
12894#[cfg(windows)]
12895fn load_trust_in(
12896 cfg: &HubConfig,
12897 directory: &TrustDirectory,
12898 requested: &str,
12899) -> LinkResult<Option<TrustState>> {
12900 let name = trust_file_name(cfg, requested)?;
12901 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
12902 let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
12903 Ok(bytes) => bytes,
12904 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
12905 Err(_) => return Err(LinkError::UnsafePath { path: name }),
12906 };
12907 let mut state: TrustState = serde_json::from_slice(&bytes)
12908 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
12909 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
12910 return Err(invalid_feed(
12911 "local identity/feed checkpoint does not match this hub and brain",
12912 ));
12913 }
12914 if state.v == 1 {
12915 if state.brain != requested {
12916 return Err(invalid_feed(
12917 "legacy checkpoint is not bound to the requested brain id",
12918 ));
12919 }
12920 state.requested = requested.to_string();
12921 } else if state.requested != requested {
12922 return Err(invalid_feed(
12923 "local identity/feed checkpoint is bound to a different requested ref",
12924 ));
12925 }
12926 Ok(Some(state))
12927}
12928
12929#[cfg(not(any(unix, windows)))]
12930fn load_trust_in(
12931 _cfg: &HubConfig,
12932 _directory: &TrustDirectory,
12933 _brain: &str,
12934) -> LinkResult<Option<TrustState>> {
12935 Err(LinkError::UnsupportedPlatform {
12936 operation: "verified link.md state",
12937 })
12938}
12939
12940#[cfg(all(test, any(unix, windows)))]
12941fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
12942 let directory = open_trust_dir(cfg)?;
12943 load_trust_in(cfg, &directory, requested)
12944}
12945
12946#[cfg(unix)]
12947fn save_trust_in(
12948 cfg: &HubConfig,
12949 directory: &TrustDirectory,
12950 state: &TrustState,
12951) -> LinkResult<()> {
12952 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12953
12954 let name_string = trust_file_name(cfg, &state.requested)?;
12955 let name = c_name(name_string.as_bytes(), &name_string)?;
12956 let mut bytes = serde_json::to_vec(state)
12957 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
12958 bytes.push(b'\n');
12959
12960 let nonce = std::time::SystemTime::now()
12961 .duration_since(std::time::UNIX_EPOCH)
12962 .unwrap_or_default()
12963 .as_nanos();
12964 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
12965 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
12966 let fd = unsafe {
12967 libc::openat(
12968 directory.as_raw_fd(),
12969 temp.as_ptr(),
12970 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12971 0o600,
12972 )
12973 };
12974 if fd < 0 {
12975 return Err(std::io::Error::last_os_error().into());
12976 }
12977 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
12978 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
12979 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12980 return Err(error.into());
12981 }
12982 drop(file);
12983 if unsafe {
12984 libc::renameat(
12985 directory.as_raw_fd(),
12986 temp.as_ptr(),
12987 directory.as_raw_fd(),
12988 name.as_ptr(),
12989 )
12990 } != 0
12991 {
12992 let error = std::io::Error::last_os_error();
12993 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12994 return Err(error.into());
12995 }
12996 directory.sync_all()?;
12997 Ok(())
12998}
12999
13000#[cfg(windows)]
13001fn save_trust_in(
13002 cfg: &HubConfig,
13003 directory: &TrustDirectory,
13004 state: &TrustState,
13005) -> LinkResult<()> {
13006 let name = trust_file_name(cfg, &state.requested)?;
13007 let mut bytes = serde_json::to_vec(state)
13008 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13009 bytes.push(b'\n');
13010 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13011 Ok(())
13012}
13013
13014#[cfg(not(any(unix, windows)))]
13015fn save_trust_in(
13016 _cfg: &HubConfig,
13017 _directory: &TrustDirectory,
13018 _state: &TrustState,
13019) -> LinkResult<()> {
13020 Err(LinkError::UnsupportedPlatform {
13021 operation: "verified link.md state",
13022 })
13023}
13024
13025#[cfg(unix)]
13026fn load_alias_in(
13027 cfg: &HubConfig,
13028 directory: &TrustDirectory,
13029 requested: &str,
13030) -> LinkResult<Option<AliasBinding>> {
13031 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13032
13033 let name_string = alias_file_name(cfg, requested)?;
13034 let name = c_name(name_string.as_bytes(), &name_string)?;
13035 let fd = unsafe {
13036 libc::openat(
13037 directory.as_raw_fd(),
13038 name.as_ptr(),
13039 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13040 )
13041 };
13042 if fd < 0 {
13043 let error = std::io::Error::last_os_error();
13044 if error.kind() == std::io::ErrorKind::NotFound {
13045 return Ok(None);
13046 }
13047 return Err(LinkError::UnsafePath { path: name_string });
13048 }
13049 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13050 if !file.metadata()?.is_file() {
13051 return Err(LinkError::UnsafePath { path: name_string });
13052 }
13053 let mut bytes = Vec::new();
13054 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
13055 if bytes.len() > 64 * 1024 {
13056 return Err(invalid_feed("local alias binding is oversized"));
13057 }
13058 let alias: AliasBinding = serde_json::from_slice(&bytes)
13059 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13060 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13061 {
13062 return Err(invalid_feed(
13063 "local alias binding does not match this hub and requested ref",
13064 ));
13065 }
13066 Ok(Some(alias))
13067}
13068
13069#[cfg(windows)]
13070fn load_alias_in(
13071 cfg: &HubConfig,
13072 directory: &TrustDirectory,
13073 requested: &str,
13074) -> LinkResult<Option<AliasBinding>> {
13075 let name = alias_file_name(cfg, requested)?;
13076 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13077 let bytes = match reader.read(Path::new(&name), 64 * 1024) {
13078 Ok(bytes) => bytes,
13079 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13080 Err(_) => return Err(LinkError::UnsafePath { path: name }),
13081 };
13082 let alias: AliasBinding = serde_json::from_slice(&bytes)
13083 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13084 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13085 {
13086 return Err(invalid_feed(
13087 "local alias binding does not match this hub and requested ref",
13088 ));
13089 }
13090 Ok(Some(alias))
13091}
13092
13093#[cfg(not(any(unix, windows)))]
13094fn load_alias_in(
13095 _cfg: &HubConfig,
13096 _directory: &TrustDirectory,
13097 _requested: &str,
13098) -> LinkResult<Option<AliasBinding>> {
13099 Err(LinkError::UnsupportedPlatform {
13100 operation: "verified link.md state",
13101 })
13102}
13103
13104#[cfg(unix)]
13105fn save_alias_in(
13106 cfg: &HubConfig,
13107 directory: &TrustDirectory,
13108 alias: &AliasBinding,
13109) -> LinkResult<()> {
13110 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13111
13112 let name_string = alias_file_name(cfg, &alias.requested)?;
13113 let name = c_name(name_string.as_bytes(), &name_string)?;
13114 let mut bytes = serde_json::to_vec(alias)
13115 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13116 bytes.push(b'\n');
13117 let nonce = std::time::SystemTime::now()
13118 .duration_since(std::time::UNIX_EPOCH)
13119 .unwrap_or_default()
13120 .as_nanos();
13121 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13122 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13123 let fd = unsafe {
13124 libc::openat(
13125 directory.as_raw_fd(),
13126 temp.as_ptr(),
13127 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13128 0o600,
13129 )
13130 };
13131 if fd < 0 {
13132 return Err(std::io::Error::last_os_error().into());
13133 }
13134 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13135 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13136 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13137 return Err(error.into());
13138 }
13139 drop(file);
13140 if unsafe {
13141 libc::renameat(
13142 directory.as_raw_fd(),
13143 temp.as_ptr(),
13144 directory.as_raw_fd(),
13145 name.as_ptr(),
13146 )
13147 } != 0
13148 {
13149 let error = std::io::Error::last_os_error();
13150 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13151 return Err(error.into());
13152 }
13153 directory.sync_all()?;
13154 Ok(())
13155}
13156
13157#[cfg(windows)]
13158fn save_alias_in(
13159 cfg: &HubConfig,
13160 directory: &TrustDirectory,
13161 alias: &AliasBinding,
13162) -> LinkResult<()> {
13163 let name = alias_file_name(cfg, &alias.requested)?;
13164 let mut bytes = serde_json::to_vec(alias)
13165 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13166 bytes.push(b'\n');
13167 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13168 Ok(())
13169}
13170
13171#[cfg(not(any(unix, windows)))]
13172fn save_alias_in(
13173 _cfg: &HubConfig,
13174 _directory: &TrustDirectory,
13175 _alias: &AliasBinding,
13176) -> LinkResult<()> {
13177 Err(LinkError::UnsupportedPlatform {
13178 operation: "verified link.md state",
13179 })
13180}
13181
13182fn load_canonical_pin(
13187 cfg: &HubConfig,
13188 directory: &TrustDirectory,
13189 requested: &str,
13190 resolved_brain: &str,
13191) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
13192 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
13193 if requested == resolved_brain {
13194 return Ok((canonical, None));
13195 }
13196
13197 let mut alias = load_alias_in(cfg, directory, requested)?;
13198 if let Some(binding) = &alias {
13199 if binding.brain != resolved_brain {
13200 return Err(LinkError::AliasRebindRequired {
13201 alias: requested.to_string(),
13202 from: binding.brain.clone(),
13203 to: resolved_brain.to_string(),
13204 });
13205 }
13206 return Ok((canonical, alias));
13207 }
13208
13209 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
13213 if legacy.brain != resolved_brain {
13214 return Err(invalid_feed(
13215 "legacy alias checkpoint names a different canonical brain",
13216 ));
13217 }
13218 if let Some(existing) = &canonical {
13219 if existing.brain != legacy.brain
13220 || existing.anchor != legacy.anchor
13221 || existing.current != legacy.current
13222 || existing.head_seq != legacy.head_seq
13223 || existing.feed_hash != legacy.feed_hash
13224 || existing.rotations != legacy.rotations
13225 {
13226 return Err(invalid_feed(
13227 "legacy alias checkpoint conflicts with the canonical checkpoint",
13228 ));
13229 }
13230 } else {
13231 let mut promoted = legacy.clone();
13232 promoted.requested = resolved_brain.to_string();
13233 promoted.home = None;
13234 save_trust_in(cfg, directory, &promoted)?;
13235 canonical = Some(promoted);
13236 }
13237 alias = Some(AliasBinding {
13238 v: 1,
13239 origin: normalized_origin(&cfg.hub)?,
13240 requested: requested.to_string(),
13241 brain: resolved_brain.to_string(),
13242 home: legacy.home,
13243 });
13244 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
13245 }
13246 Ok((canonical, alias))
13247}
13248
13249pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
13254 require_hardened_filesystem("verified alias rebind")?;
13255 require_safe_ref(alias)?;
13256 require_safe_ref(from)?;
13257 require_safe_ref(to)?;
13258 if crate::ulid::is_ulid(alias)
13259 || !crate::ulid::is_ulid(from)
13260 || !crate::ulid::is_ulid(to)
13261 || from == to
13262 {
13263 return Err(LinkError::InvalidPack {
13264 message:
13265 "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
13266 .to_string(),
13267 });
13268 }
13269
13270 let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
13271 message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
13272 })?;
13273 accept_v2_head(cfg, &verified)?;
13274
13275 let alias_response = ensure_ok(
13276 request(
13277 cfg,
13278 "GET",
13279 &format!("/api/hub/brains/{alias}/v2/head"),
13280 None,
13281 Auth::Required,
13282 )?,
13283 "resolve alias for explicit rebind",
13284 )?;
13285 let resolved: V2HeadResponse = serde_json::from_value(alias_response)
13286 .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
13287 if resolved.v != 2 || resolved.brain_id != to {
13288 return Err(LinkError::RemoteAdvancedDuringSync);
13289 }
13290
13291 let directory = open_trust_dir(cfg)?;
13292 let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
13293 let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
13294 message: "the requested alias has no existing local binding to replace".to_string(),
13295 })?;
13296 if binding.brain != from {
13297 return Err(LinkError::AliasRebindRequired {
13298 alias: alias.to_string(),
13299 from: binding.brain,
13300 to: to.to_string(),
13301 });
13302 }
13303 save_alias_in(
13304 cfg,
13305 &directory,
13306 &AliasBinding {
13307 v: 1,
13308 origin: normalized_origin(&cfg.hub)?,
13309 requested: alias.to_string(),
13310 brain: to.to_string(),
13311 home: binding.home,
13312 },
13313 )?;
13314 Ok(json!({
13315 "v": 2,
13316 "alias": alias,
13317 "from": from,
13318 "to": to,
13319 "outcome": "alias_rebound",
13320 }))
13321}
13322
13323fn save_canonical_pin_and_alias(
13324 cfg: &HubConfig,
13325 directory: &TrustDirectory,
13326 requested: &str,
13327 resolved_brain: &str,
13328 mut state: TrustState,
13329 existing_alias: Option<&AliasBinding>,
13330) -> LinkResult<()> {
13331 state.requested = resolved_brain.to_string();
13332 state.brain = resolved_brain.to_string();
13333 state.home = None;
13334 save_trust_in(cfg, directory, &state)?;
13335 if requested != resolved_brain {
13336 save_alias_in(
13337 cfg,
13338 directory,
13339 &AliasBinding {
13340 v: 1,
13341 origin: normalized_origin(&cfg.hub)?,
13342 requested: requested.to_string(),
13343 brain: resolved_brain.to_string(),
13344 home: existing_alias.and_then(|alias| alias.home.clone()),
13345 },
13346 )?;
13347 }
13348 Ok(())
13349}
13350
13351fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
13352 const ED25519_SPKI_PREFIX: &[u8] = &[
13353 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13354 ];
13355 let entry = &item.entry;
13356 let public_der = URL_SAFE_NO_PAD
13357 .decode(&entry.public_key)
13358 .map_err(|_| invalid_feed("public key is not base64url"))?;
13359 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
13360 || !public_der.starts_with(ED25519_SPKI_PREFIX)
13361 {
13362 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
13363 }
13364 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
13365 if entry.brain != format!("ed25519:{fingerprint}") {
13366 return Err(invalid_feed(
13367 "brain fingerprint does not match its public key",
13368 ));
13369 }
13370 let _ = verify_identity_chain(identity, None)?;
13372 let mut chain: Vec<(&str, &str)> = identity
13373 .previous
13374 .iter()
13375 .rev()
13376 .map(|previous| {
13377 (
13378 previous.fingerprint.as_str(),
13379 previous.public_key_spki.as_str(),
13380 )
13381 })
13382 .collect();
13383 chain.push((&identity.fingerprint, &identity.public_key_spki));
13384 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
13385 *known_fingerprint == fingerprint && *spki == entry.public_key
13386 });
13387 let Some(signer_index) = signer_index else {
13388 return Err(invalid_feed(
13389 "entry signer is not this brain's identity (current or rotated-from)",
13390 ));
13391 };
13392 let lower_boundary = if signer_index == 0 {
13393 None
13394 } else {
13395 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
13396 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13397 Some(prior.prior_head_seq)
13398 };
13399 let upper_boundary = if signer_index == identity.rotations.len() {
13400 None
13401 } else {
13402 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
13403 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13404 Some(next.prior_head_seq)
13405 };
13406 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
13407 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
13408 {
13409 return Err(invalid_feed(
13410 "entry signer is outside its authenticated rotation epoch",
13411 ));
13412 }
13413 let unsigned = UnsignedFeedEntry {
13414 v: entry.v,
13415 seq: entry.seq,
13416 ts: &entry.ts,
13417 brain: &entry.brain,
13418 public_key: &entry.public_key,
13419 kind: &entry.kind,
13420 op: &entry.op,
13421 pack_sha256: &entry.pack_sha256,
13422 files: &entry.files,
13423 removed: &entry.removed,
13424 prev_entry_hash: &entry.prev_entry_hash,
13425 };
13426 let message =
13427 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
13428 let signature = URL_SAFE_NO_PAD
13429 .decode(&entry.sig)
13430 .map_err(|_| invalid_feed("signature is not base64url"))?;
13431 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
13432 .verify(&message, &signature)
13433 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
13434
13435 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
13436 exact.push(b'\n');
13437 let actual_hash = format!("{:x}", Sha256::digest(&exact));
13438 if actual_hash != item.hash {
13439 return Err(invalid_feed("entry SHA-256 does not match"));
13440 }
13441 Ok(())
13442}
13443
13444#[derive(Serialize)]
13450struct UnsignedRotation<'a> {
13451 v: u8,
13452 op: &'a str,
13453 brain: &'a str,
13454 public_key: &'a str,
13455 new_brain: &'a str,
13456 new_public_key: &'a str,
13457 prior_head_seq: u64,
13458 prior_feed_hash: Option<&'a str>,
13459 ts: String,
13460}
13461
13462#[derive(Debug, Deserialize, Serialize)]
13467#[serde(deny_unknown_fields)]
13468struct RotationJournal {
13469 v: u8,
13470 origin: String,
13471 brain: String,
13472 old_brain: String,
13473 new_brain: String,
13474 prior_head_seq: u64,
13475 prior_feed_hash: Option<String>,
13476 statement: String,
13477}
13478
13479fn rotation_journal_path(key_path: &Path) -> PathBuf {
13480 let mut path = key_path.as_os_str().to_os_string();
13481 path.push(".rotation.json");
13482 PathBuf::from(path)
13483}
13484
13485fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
13486 #[cfg(unix)]
13487 let file = {
13488 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13489 use std::os::unix::ffi::OsStrExt as _;
13490 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13491 .map_err(|error| {
13492 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
13493 })?;
13494 let leaf_name = path
13495 .file_name()
13496 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
13497 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
13498 let fd = unsafe {
13499 libc::openat(
13500 parent.as_raw_fd(),
13501 leaf.as_ptr(),
13502 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13503 )
13504 };
13505 if fd < 0 {
13506 return Err(bad_agent_key(
13507 "the rotation journal must be an existing regular file without symlink ancestors",
13508 ));
13509 }
13510 unsafe { std::fs::File::from_raw_fd(fd) }
13511 };
13512 #[cfg(not(unix))]
13513 let file = std::fs::File::open(path)
13514 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
13515 let metadata = file
13516 .metadata()
13517 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
13518 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
13519 return Err(bad_agent_key(
13520 "the rotation journal must be a bounded regular file",
13521 ));
13522 }
13523 #[cfg(unix)]
13524 {
13525 use std::os::unix::fs::PermissionsExt as _;
13526 if metadata.permissions().mode() & 0o077 != 0 {
13527 return Err(bad_agent_key(
13528 "the rotation journal is accessible to group/other; set mode 0600",
13529 ));
13530 }
13531 }
13532 serde_json::from_reader(file)
13533 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
13534}
13535
13536fn remove_rotation_journal(path: &Path) {
13537 #[cfg(unix)]
13538 {
13539 use std::os::fd::AsRawFd as _;
13540 use std::os::unix::ffi::OsStrExt as _;
13541 let Ok(parent) =
13542 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13543 else {
13544 return;
13545 };
13546 let Some(leaf_name) = path.file_name() else {
13547 return;
13548 };
13549 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
13550 return;
13551 };
13552 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
13553 let _ = parent.sync_all();
13554 }
13555 }
13556 #[cfg(not(unix))]
13557 {
13558 let _ = std::fs::remove_file(path);
13559 }
13560}
13561
13562fn validate_rotation_journal(
13563 journal: &RotationJournal,
13564 cfg: &HubConfig,
13565 canonical_brain: &str,
13566 old_key: &AgentSigningKey,
13567 new_key: &AgentSigningKey,
13568 head: &Head,
13569) -> LinkResult<()> {
13570 if journal.v != 1
13571 || journal.origin != normalized_origin(&cfg.hub)?
13572 || journal.brain != canonical_brain
13573 || journal.old_brain != old_key.multikey
13574 || journal.new_brain != new_key.multikey
13575 || journal.prior_head_seq != head.seq
13576 || journal.prior_feed_hash != head.feed_hash
13577 {
13578 return Err(invalid_feed(
13579 "rotation journal does not match the verified key and feed boundary",
13580 ));
13581 }
13582 let statement: RotationStatement = serde_json::from_str(&journal.statement)
13583 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
13584 if statement.prior_head_seq != journal.prior_head_seq
13585 || statement.prior_feed_hash != journal.prior_feed_hash
13586 || statement.brain != old_key.multikey
13587 || statement.public_key != old_key.public_key_spki
13588 || statement.new_brain != new_key.multikey
13589 || statement.new_public_key != new_key.public_key_spki
13590 {
13591 return Err(invalid_feed(
13592 "rotation journal statement does not match its durable intent",
13593 ));
13594 }
13595 let identity = FeedIdentity {
13596 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13597 public_key_spki: new_key.public_key_spki.clone(),
13598 previous: vec![PreviousIdentity {
13599 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
13600 public_key_spki: old_key.public_key_spki.clone(),
13601 }],
13602 rotations: vec![journal.statement.clone()],
13603 };
13604 verify_identity_chain(&identity, None)?;
13605 Ok(())
13606}
13607
13608#[derive(Debug, Serialize)]
13610pub struct RotationReport {
13611 pub brain: String,
13613 pub multikey: String,
13615 #[serde(rename = "keyFile")]
13617 pub key_file: String,
13618 pub previous: Vec<String>,
13620}
13621
13622pub fn rotate_brain_key(
13628 cfg: &HubConfig,
13629 brain: &str,
13630 old_key: &AgentSigningKey,
13631 out: &Path,
13632) -> LinkResult<RotationReport> {
13633 require_hardened_filesystem("key rotation")?;
13634 require_safe_ref(brain)?;
13635 let new_key = if out.exists() {
13639 load_signing_key(out)?
13640 } else {
13641 let rng = ring::rand::SystemRandom::new();
13642 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
13643 .map_err(|_| bad_agent_key("key generation failed"))?;
13644 let pair = agent_keypair(pkcs8.as_ref())?;
13645 let (public_key_spki, multikey) = public_identity_for(&pair);
13646 write_secret_new(
13647 out,
13648 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
13649 )?;
13650 AgentSigningKey {
13651 pkcs8: pkcs8.as_ref().to_vec(),
13652 multikey,
13653 public_key_spki,
13654 }
13655 };
13656 let new_spki = new_key.public_key_spki.clone();
13657 let new_multikey = new_key.multikey.clone();
13658 let journal_path = rotation_journal_path(out);
13659 let before_v2 = v2_verified_head(cfg, brain)?;
13660 let (canonical_brain, served_identity, observed_head, v2_profile) =
13661 if let Some(head) = before_v2 {
13662 let observed = Head {
13663 brain: head.brain_id.clone(),
13664 seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13665 updated_at: head
13666 .pointer
13667 .as_ref()
13668 .map(|pointer| pointer.signed_at.clone()),
13669 feed_hash: head
13670 .pointer
13671 .as_ref()
13672 .map(|pointer| pointer.feed_hash.clone()),
13673 verified: true,
13674 };
13675 let identity = v2_identity(&head.identity);
13676 let canonical = head.brain_id.clone();
13677 accept_v2_head(cfg, &head)?;
13678 (canonical, identity, observed, true)
13679 } else {
13680 let remote = verified_remote_head(cfg, brain, false)?;
13681 let identity = remote
13682 .identity
13683 .clone()
13684 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
13685 (remote.head.brain.clone(), identity, remote.head, false)
13686 };
13687 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
13688 let already_rotated = served_multikey == new_multikey;
13689 if already_rotated && !journal_path.exists() {
13694 remove_rotation_journal(&journal_path);
13695 return Ok(RotationReport {
13696 brain: brain.to_string(),
13697 multikey: new_multikey,
13698 key_file: out.display().to_string(),
13699 previous: served_identity
13700 .previous
13701 .iter()
13702 .map(|identity| format!("ed25519:{}", identity.fingerprint))
13703 .collect(),
13704 });
13705 }
13706 if !already_rotated && served_multikey != old_key.multikey {
13707 return Err(invalid_feed(
13708 "the supplied old key is not the brain's verified current identity",
13709 ));
13710 }
13711
13712 let journal = if journal_path.exists() {
13713 read_rotation_journal(&journal_path)?
13714 } else {
13715 let ts = crate::now()
13716 .with_timezone(&chrono::Utc)
13717 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
13718 .to_string();
13719 let unsigned = serde_json::to_string(&UnsignedRotation {
13720 v: 1,
13721 op: "rotate",
13722 brain: &old_key.multikey,
13723 public_key: &old_key.public_key_spki,
13724 new_brain: &new_multikey,
13725 new_public_key: &new_spki,
13726 prior_head_seq: observed_head.seq,
13727 prior_feed_hash: observed_head.feed_hash.as_deref(),
13728 ts,
13729 })
13730 .expect("serialize rotation");
13731 let old_pair = agent_keypair(&old_key.pkcs8)?;
13732 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
13733 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
13734 let journal = RotationJournal {
13735 v: 1,
13736 origin: normalized_origin(&cfg.hub)?,
13737 brain: canonical_brain.clone(),
13738 old_brain: old_key.multikey.clone(),
13739 new_brain: new_multikey.clone(),
13740 prior_head_seq: observed_head.seq,
13741 prior_feed_hash: observed_head.feed_hash.clone(),
13742 statement,
13743 };
13744 let mut exact = serde_json::to_vec(&journal)
13745 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
13746 exact.push(b'\n');
13747 if write_secret_new(&journal_path, &exact).is_err() {
13748 read_rotation_journal(&journal_path)?
13751 } else {
13752 journal
13753 }
13754 };
13755 validate_rotation_journal(
13756 &journal,
13757 cfg,
13758 &canonical_brain,
13759 old_key,
13760 &new_key,
13761 &observed_head,
13762 )?;
13763
13764 let body = json!({ "statement": journal.statement });
13765 let path = format!("/api/hub/brains/{brain}/rotate");
13766 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
13767 let attempted_failure = match attempted {
13768 Ok(response) if (200..300).contains(&response.status) => None,
13769 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
13770 Err(error) => Some(error),
13771 };
13772
13773 let identity = if v2_profile {
13777 match v2_verified_head(cfg, brain) {
13778 Ok(Some(after)) => {
13779 let identity = v2_identity(&after.identity);
13780 accept_v2_head(cfg, &after)?;
13781 identity
13782 }
13783 Ok(None) => {
13784 return Err(attempted_failure.unwrap_or_else(|| {
13785 invalid_feed("rotated v2 brain no longer serves a v2 head")
13786 }));
13787 }
13788 Err(error) => return Err(attempted_failure.unwrap_or(error)),
13789 }
13790 } else {
13791 match verified_remote_head(cfg, brain, false) {
13792 Ok(after) => after
13793 .identity
13794 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
13795 Err(error) => return Err(attempted_failure.unwrap_or(error)),
13796 }
13797 };
13798 if format!("ed25519:{}", identity.fingerprint) != new_multikey
13799 || identity.public_key_spki != new_spki
13800 {
13801 return Err(attempted_failure.unwrap_or_else(|| {
13802 invalid_feed("hub acknowledged rotation without committing the verified new identity")
13803 }));
13804 }
13805 if v2_profile {
13806 if let Some(error) = attempted_failure {
13807 return Err(error);
13812 }
13813 }
13814 let previous = identity
13815 .previous
13816 .iter()
13817 .map(|prior| format!("ed25519:{}", prior.fingerprint))
13818 .collect();
13819 remove_rotation_journal(&journal_path);
13820
13821 Ok(RotationReport {
13822 brain: brain.to_string(),
13823 multikey: new_multikey,
13824 key_file: out.display().to_string(),
13825 previous,
13826 })
13827}
13828
13829#[derive(Debug, Serialize)]
13835pub struct MirrorReport {
13836 pub brain: String,
13838 #[serde(rename = "headSeq")]
13840 pub head_seq: u64,
13841 #[serde(rename = "feedHash")]
13843 pub feed_hash: Option<String>,
13844 pub entries: u64,
13846 pub pinned: String,
13848 pub files: usize,
13850}
13851
13852pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
13854
13855#[derive(Debug)]
13857pub struct VerifiedMirrorMaterial {
13858 pub brain: String,
13859 pub head_seq: u64,
13860 pub feed_hash: Option<String>,
13861 pub identity: serde_json::Value,
13862 pub entries: Vec<(u64, String, String)>,
13864 pub pack_sha256: Option<String>,
13865}
13866
13867#[derive(Deserialize)]
13868#[serde(deny_unknown_fields)]
13869struct StoredMirrorHead {
13870 brain: String,
13871 #[serde(rename = "headSeq")]
13872 head_seq: u64,
13873 #[serde(rename = "feedHash")]
13874 feed_hash: Option<String>,
13875}
13876
13877pub fn verify_mirror_material(
13880 head_bytes: &[u8],
13881 identity_bytes: &[u8],
13882 feed_bytes: &[Vec<u8>],
13883 snapshot_pack: Option<&[u8]>,
13884 expected_anchor: &str,
13885) -> LinkResult<VerifiedMirrorMaterial> {
13886 let snapshot_hash = snapshot_pack
13887 .filter(|pack| !pack.is_empty())
13888 .map(content_sha256);
13889 verify_mirror_material_with_pack_hash(
13890 head_bytes,
13891 identity_bytes,
13892 feed_bytes,
13893 snapshot_hash.as_deref(),
13894 expected_anchor,
13895 )
13896}
13897
13898pub fn verify_mirror_material_with_pack_hash(
13902 head_bytes: &[u8],
13903 identity_bytes: &[u8],
13904 feed_bytes: &[Vec<u8>],
13905 snapshot_pack_sha256: Option<&str>,
13906 expected_anchor: &str,
13907) -> LinkResult<VerifiedMirrorMaterial> {
13908 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
13909 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
13910 require_safe_ref(&head.brain)?;
13911 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
13912 return Err(invalid_feed(
13913 "stored mirror feed count does not match its bounded head sequence",
13914 ));
13915 }
13916 let aggregate = feed_bytes
13917 .iter()
13918 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
13919 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
13920 if aggregate > MAX_FEED_REPLAY_BYTES {
13921 return Err(invalid_feed(
13922 "stored mirror feed metadata exceeds the aggregate limit",
13923 ));
13924 }
13925 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
13926 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
13927 let anchor = verify_identity_chain(&identity, None)?;
13928 if anchor != expected_anchor {
13929 return Err(invalid_feed(
13930 "stored mirror identity does not descend from the explicitly trusted anchor",
13931 ));
13932 }
13933
13934 let mut entries = Vec::with_capacity(feed_bytes.len());
13935 let mut items = Vec::with_capacity(feed_bytes.len());
13936 let mut previous_hash = None;
13937 let mut pack_sha256 = None;
13938 for (index, bytes) in feed_bytes.iter().enumerate() {
13939 let exact = bytes
13940 .strip_suffix(b"\n")
13941 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
13942 if exact.ends_with(b"\n") {
13943 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
13944 }
13945 let entry: FeedEntry = serde_json::from_slice(exact)
13946 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
13947 let expected_seq = index as u64 + 1;
13948 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
13949 return Err(invalid_feed(
13950 "stored mirror feed is not contiguous and hash-chained",
13951 ));
13952 }
13953 let canonical = serde_json::to_vec(&entry)
13954 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
13955 if canonical != exact {
13956 return Err(invalid_feed(
13957 "stored feed entry is not in normative serialization",
13958 ));
13959 }
13960 let hash = content_sha256(bytes);
13961 let item = FeedItem {
13962 hash: hash.clone(),
13963 entry,
13964 };
13965 verify_feed_item(&item, &identity)?;
13966 previous_hash = Some(hash.clone());
13967 if expected_seq == head.head_seq {
13968 pack_sha256 = Some(item.entry.pack_sha256.clone());
13969 }
13970 entries.push((
13971 expected_seq,
13972 std::str::from_utf8(exact)
13973 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
13974 .to_string(),
13975 hash,
13976 ));
13977 items.push(item);
13978 }
13979 if previous_hash != head.feed_hash {
13980 return Err(invalid_feed(
13981 "stored mirror feed does not converge on its advertised head",
13982 ));
13983 }
13984 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
13985 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
13986 (0, None, None) => {}
13987 (_, Some(actual), Some(expected)) if actual == expected => {}
13988 _ => {
13989 return Err(LinkError::InvalidPack {
13990 message: "stored snapshot pack does not match the signed head digest".to_string(),
13991 });
13992 }
13993 }
13994 let identity_value = serde_json::to_value(&identity)
13995 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
13996 Ok(VerifiedMirrorMaterial {
13997 brain: head.brain,
13998 head_seq: head.head_seq,
13999 feed_hash: head.feed_hash,
14000 identity: identity_value,
14001 entries,
14002 pack_sha256,
14003 })
14004}
14005
14006pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
14009 format!(
14010 "{:x}",
14011 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
14012 )
14013}
14014
14015pub fn content_sha256(bytes: &[u8]) -> String {
14018 format!("{:x}", Sha256::digest(bytes))
14019}
14020
14021pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
14023 let mut digest = Sha256::new();
14024 let mut buffer = [0u8; 64 * 1024];
14025 loop {
14026 let read = reader.read(&mut buffer)?;
14027 if read == 0 {
14028 break;
14029 }
14030 digest.update(&buffer[..read]);
14031 }
14032 Ok(format!("{:x}", digest.finalize()))
14033}
14034
14035#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
14043pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
14044 require_hardened_filesystem("mirror")?;
14045 require_safe_ref(brain)?;
14046 #[cfg(windows)]
14047 {
14048 let _ = (cfg, dest);
14049 return Err(LinkError::UnsupportedPlatform {
14050 operation: "atomic whole-mirror replacement on Windows",
14051 });
14052 }
14053 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
14054 let name = dest
14055 .file_name()
14056 .and_then(|name| name.to_str())
14057 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
14058 .ok_or_else(|| LinkError::UnsafePath {
14059 path: dest.display().to_string(),
14060 })?;
14061 #[cfg(unix)]
14062 let parent_dir = open_or_create_dir_nofollow(parent)?;
14063 #[cfg(unix)]
14064 use std::os::fd::AsRawFd as _;
14065 #[cfg(unix)]
14066 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
14067 #[cfg(unix)]
14068 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
14069 None => false,
14070 Some(true) => true,
14071 Some(false) => {
14072 return Err(LinkError::UnsafePath {
14073 path: dest.display().to_string(),
14074 });
14075 }
14076 };
14077
14078 #[cfg(unix)]
14081 let legacy_backup_name = c_name(
14082 format!(".{name}.dbmd-backup").as_bytes(),
14083 &dest.display().to_string(),
14084 )?;
14085 #[cfg(unix)]
14086 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
14087 return Err(LinkError::UnsafePath {
14088 path: parent
14089 .join(format!(".{name}.dbmd-backup"))
14090 .display()
14091 .to_string(),
14092 });
14093 }
14094
14095 let nonce = std::time::SystemTime::now()
14096 .duration_since(std::time::UNIX_EPOCH)
14097 .unwrap_or_default()
14098 .as_nanos();
14099 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
14100 #[cfg(unix)]
14101 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
14102 #[cfg(unix)]
14103 let stage_dir = create_dir_exclusive_at(
14104 parent_dir.as_raw_fd(),
14105 &stage_name,
14106 &dest.display().to_string(),
14107 )?;
14108
14109 let assembled = (|| -> LinkResult<MirrorReport> {
14110 let remote = verified_remote_head(cfg, brain, true)?;
14111 let brain_id = remote.head.brain.clone();
14112 let identity = remote
14113 .identity
14114 .as_ref()
14115 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
14116 let anchor = remote
14117 .anchor
14118 .clone()
14119 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
14120 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
14121 let snapshot_entries = parse_store_pack(pack.clone())?;
14122 let snapshot_count = snapshot_entries.len();
14123 let mut staged_entries = snapshot_entries;
14124 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
14125 for item in &remote.entries {
14126 let mut exact = serde_json::to_vec(&item.entry)
14127 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
14128 exact.push(b'\n');
14129 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
14130 return Err(invalid_feed(
14131 "serialized mirror entry differs from its verified hash",
14132 ));
14133 }
14134 staged_entries.push((
14135 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
14136 exact,
14137 ));
14138 }
14139 let mut identity_bytes = serde_json::to_vec(identity)
14140 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
14141 identity_bytes.push(b'\n');
14142 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
14143 let mut head_bytes = serde_json::to_vec(&json!({
14144 "brain": brain_id,
14145 "headSeq": remote.head.seq,
14146 "feedHash": remote.head.feed_hash,
14147 }))
14148 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
14149 head_bytes.push(b'\n');
14150 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
14151 staged_entries.push((
14152 CONFIG_REL_PATH.to_string(),
14153 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
14154 ));
14155 #[cfg(unix)]
14156 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
14157
14158 Ok(MirrorReport {
14159 brain: brain_id,
14160 head_seq: remote.head.seq,
14161 feed_hash: remote.head.feed_hash,
14162 entries: remote.entries.len() as u64,
14163 pinned: anchor,
14164 files: snapshot_count,
14165 })
14166 })();
14167
14168 let report = match assembled {
14169 Ok(report) => report,
14170 Err(error) => {
14171 #[cfg(unix)]
14172 let _ = remove_tree_at(
14173 parent_dir.as_raw_fd(),
14174 &stage_name,
14175 &dest.display().to_string(),
14176 );
14177 return Err(error);
14178 }
14179 };
14180
14181 #[cfg(unix)]
14182 if let Err(error) =
14183 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
14184 {
14185 let _ = remove_tree_at(
14186 parent_dir.as_raw_fd(),
14187 &stage_name,
14188 &dest.display().to_string(),
14189 );
14190 return Err(error);
14191 }
14192 #[cfg(unix)]
14195 if dest_exists {
14196 remove_tree_at(
14197 parent_dir.as_raw_fd(),
14198 &stage_name,
14199 &dest.display().to_string(),
14200 )?;
14201 }
14202 #[cfg(unix)]
14203 parent_dir.sync_all()?;
14204 Ok(report)
14205}
14206
14207fn verified_remote_head(
14208 cfg: &HubConfig,
14209 brain: &str,
14210 require_full_chain: bool,
14211) -> LinkResult<VerifiedRemote> {
14212 require_hardened_filesystem("verified link.md state")?;
14213 require_safe_ref(brain)?;
14214 let trust_directory = open_trust_dir(cfg)?;
14218 let path = format!("/api/hub/brains/{brain}");
14219 let body = ensure_ok(
14220 request(cfg, "GET", &path, None, Auth::Required)?,
14221 "subscribe",
14222 )?;
14223 let resolved_brain = body
14224 .get("id")
14225 .and_then(Value::as_str)
14226 .filter(|id| crate::ulid::is_ulid(id))
14227 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
14228 .to_string();
14229 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
14230 return Err(invalid_feed(
14231 "brain card id differs from the explicitly requested brain id",
14232 ));
14233 }
14234 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
14239 let (pinned, alias_binding) =
14240 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
14241 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
14242 let advertised_hash = body
14243 .get("feedHash")
14244 .and_then(Value::as_str)
14245 .map(str::to_string);
14246 let updated_at = body
14247 .get("updatedAt")
14248 .and_then(Value::as_str)
14249 .map(str::to_string);
14250 if let Some(pin) = &pinned {
14251 if seq < pin.head_seq {
14252 return Err(invalid_feed(format!(
14253 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
14254 pin.head_seq
14255 )));
14256 }
14257 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
14258 return Err(invalid_feed(
14259 "feed equivocation: the checkpoint sequence now has a different hash",
14260 ));
14261 }
14262 }
14263 if seq == 0 {
14264 if advertised_hash.is_some() {
14265 return Err(invalid_feed("an empty feed advertised a head hash"));
14266 }
14267 let identity: FeedIdentity = serde_json::from_value(
14268 body.get("identity")
14269 .cloned()
14270 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
14271 )
14272 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
14273 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
14274 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
14279 save_canonical_pin_and_alias(
14280 cfg,
14281 &trust_directory,
14282 brain,
14283 &resolved_brain,
14284 TrustState {
14285 v: 2,
14286 origin: normalized_origin(&cfg.hub)?,
14287 requested: resolved_brain.clone(),
14288 brain: resolved_brain.clone(),
14289 home: None,
14290 anchor: anchor.clone(),
14291 current: format!("ed25519:{}", identity.fingerprint),
14292 head_seq: 0,
14293 feed_hash: None,
14294 rotations: identity.rotations.clone(),
14295 hub_signer: None,
14296 protocol_profile: None,
14297 },
14298 alias_binding.as_ref(),
14299 )?;
14300 return Ok(VerifiedRemote {
14301 head: Head {
14302 brain: resolved_brain,
14303 seq,
14304 updated_at,
14305 feed_hash: None,
14306 verified: true,
14307 },
14308 identity: Some(identity),
14309 head_entry: None,
14310 entries: Vec::new(),
14311 anchor: Some(anchor),
14312 });
14313 }
14314 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
14315 return Err(invalid_feed(
14316 "non-empty feed did not advertise a valid SHA-256 head",
14317 ));
14318 }
14319
14320 let replay_head_only = !require_full_chain
14324 && pinned
14325 .as_ref()
14326 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
14327 let mut after = if replay_head_only {
14328 seq - 1
14329 } else if require_full_chain || pinned.is_none() {
14330 0
14331 } else {
14332 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
14333 };
14334 let mut expected_seq = after + 1;
14335 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
14336 None
14337 } else {
14338 pinned
14339 .as_ref()
14340 .and_then(|checkpoint| checkpoint.feed_hash.clone())
14341 };
14342 let mut identity: Option<FeedIdentity> = None;
14343 let mut anchor: Option<String> = None;
14344 let mut head_entry: Option<FeedItem> = None;
14345 let mut all_entries = Vec::new();
14346 let mut observed_entries = Vec::new();
14347 let replay_count = seq
14348 .checked_sub(after)
14349 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
14350 if replay_count > MAX_FEED_REPLAY_ENTRIES {
14351 return Err(invalid_feed(format!(
14352 "feed replay requires {replay_count} entries, over the client cap"
14353 )));
14354 }
14355 let mut replay_bytes = 0u64;
14356
14357 loop {
14358 let feed_bytes = ensure_raw_ok(
14359 request_raw(
14360 cfg,
14361 "GET",
14362 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
14363 None,
14364 Auth::Required,
14365 MAX_FEED_RESPONSE_BYTES,
14366 )?,
14367 "subscribe feed",
14368 )?;
14369 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
14370 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
14371 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
14372 return Err(invalid_feed("brain card and feed head disagree"));
14373 }
14374 if feed.entries.len() > FEED_PAGE_LIMIT {
14375 return Err(invalid_feed("feed page exceeds the requested entry limit"));
14376 }
14377 if feed.scope_limited {
14378 if require_full_chain {
14379 return Err(invalid_feed(
14380 "path-scoped grants cannot verify a full snapshot chain",
14381 ));
14382 }
14383 return Ok(VerifiedRemote {
14384 head: Head {
14385 brain: resolved_brain,
14386 seq,
14387 updated_at,
14388 feed_hash: advertised_hash,
14389 verified: false,
14390 },
14391 identity: None,
14392 head_entry: None,
14393 entries: Vec::new(),
14394 anchor: None,
14395 });
14396 }
14397 let page_identity = feed
14398 .identity
14399 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14400 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
14401 if identity
14402 .as_ref()
14403 .is_some_and(|existing| existing != &page_identity)
14404 {
14405 return Err(invalid_feed("identity changed while reading the feed"));
14406 }
14407 if anchor
14408 .as_ref()
14409 .is_some_and(|existing| existing != &page_anchor)
14410 {
14411 return Err(invalid_feed(
14412 "identity anchor changed while reading the feed",
14413 ));
14414 }
14415 identity = Some(page_identity.clone());
14416 if anchor.is_none() {
14417 anchor = Some(page_anchor);
14418 }
14419 if feed.entries.is_empty() {
14420 return Err(invalid_feed("feed page was empty before the signed head"));
14421 }
14422
14423 for item in feed.entries {
14424 if item.entry.seq != expected_seq {
14425 return Err(invalid_feed(format!(
14426 "expected entry {expected_seq}, feed served {}",
14427 item.entry.seq
14428 )));
14429 }
14430 if item.entry.seq > seq {
14431 return Err(invalid_feed("feed advanced past the card snapshot"));
14432 }
14433 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
14434 return Err(invalid_feed(format!(
14435 "entry {} does not chain to the local checkpoint",
14436 item.entry.seq
14437 )));
14438 }
14439 verify_feed_item(&item, &page_identity)?;
14440 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
14441 replay_bytes = replay_bytes.saturating_add(
14442 serde_json::to_vec(&item)
14443 .map_err(|_| invalid_feed("could not size feed entry"))?
14444 .len() as u64,
14445 );
14446 if replay_bytes > MAX_FEED_REPLAY_BYTES {
14447 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
14448 }
14449 previous_hash = Some(item.hash.clone());
14450 after = item.entry.seq;
14451 expected_seq = expected_seq
14452 .checked_add(1)
14453 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
14454 if require_full_chain {
14455 all_entries.push(item.clone());
14456 }
14457 observed_entries.push(item.clone());
14458 head_entry = Some(item);
14459 }
14460 if after == seq {
14461 break;
14462 }
14463 }
14464
14465 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
14466 return Err(invalid_feed(
14467 "verified chain does not converge on the advertised head",
14468 ));
14469 }
14470 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14471 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
14472 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
14473 save_canonical_pin_and_alias(
14474 cfg,
14475 &trust_directory,
14476 brain,
14477 &resolved_brain,
14478 TrustState {
14479 v: 2,
14480 origin: normalized_origin(&cfg.hub)?,
14481 requested: resolved_brain.clone(),
14482 brain: resolved_brain.clone(),
14483 home: None,
14484 anchor: anchor.clone(),
14485 current: format!("ed25519:{}", identity.fingerprint),
14486 head_seq: seq,
14487 feed_hash: advertised_hash.clone(),
14488 rotations: identity.rotations.clone(),
14489 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
14490 protocol_profile: pinned
14491 .as_ref()
14492 .and_then(|state| state.protocol_profile.clone()),
14493 },
14494 alias_binding.as_ref(),
14495 )?;
14496 Ok(VerifiedRemote {
14497 head: Head {
14498 brain: resolved_brain,
14499 seq,
14500 updated_at,
14501 feed_hash: advertised_hash,
14502 verified: true,
14503 },
14504 identity: Some(identity),
14505 head_entry,
14506 entries: all_entries,
14507 anchor: Some(anchor),
14508 })
14509}
14510
14511pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
14516 if let Some(verified) = v2_verified_head(cfg, brain)? {
14517 let observation = Head {
14518 brain: verified.brain_id.clone(),
14519 seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14520 updated_at: verified
14521 .pointer
14522 .as_ref()
14523 .map(|pointer| pointer.signed_at.clone()),
14524 feed_hash: verified
14525 .pointer
14526 .as_ref()
14527 .map(|pointer| pointer.feed_hash.clone()),
14528 verified: true,
14529 };
14530 accept_v2_head(cfg, &verified)?;
14531 return Ok(observation);
14532 }
14533 Ok(verified_remote_head(cfg, brain, false)?.head)
14534}
14535
14536#[cfg(test)]
14537mod tests {
14538 use super::*;
14539
14540 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
14541
14542 fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
14543 json!({
14544 "sha256": "a".repeat(64),
14545 "bytes": 10,
14546 "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
14547 })
14548 }
14549
14550 #[test]
14551 fn upload_reservations_batch_by_count_and_by_size() {
14552 let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
14556 let batches = batch_upload_declarations(declarations.clone());
14557
14558 assert!(batches.len() > 1, "5,000 blobs must not ride one request");
14559 for batch in &batches {
14560 assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
14561 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14562 .expect("batch serializes")
14563 .len();
14564 assert!(
14565 bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
14566 "batch body {bytes} exceeds the reservation budget"
14567 );
14568 }
14569 let flattened: Vec<Value> = batches.into_iter().flatten().collect();
14570 assert_eq!(
14571 flattened, declarations,
14572 "batching must preserve the set and order"
14573 );
14574 }
14575
14576 #[test]
14577 fn only_load_shaped_hub_answers_are_worth_asking_again() {
14578 for status in [408, 429, 500, 502, 503, 504] {
14583 assert!(is_retryable_hub_status(status), "{status} is load-shaped");
14584 }
14585 for status in [400, 401, 403, 404, 409, 413, 422] {
14586 assert!(
14587 !is_retryable_hub_status(status),
14588 "{status} states something about the request"
14589 );
14590 }
14591 let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
14593 assert!(total >= 60_000, "backoff totals only {total}ms");
14594 }
14595
14596 #[test]
14597 fn a_batch_shares_a_connection_only_within_one_authority() {
14598 let cfg = HubConfig {
14603 hub: "https://www.sevrahq.com".to_string(),
14604 key: Some("k".to_string()),
14605 agent_key: None,
14606 brain_key: None,
14607 state_dir: PathBuf::from("."),
14608 store_selected: false,
14609 };
14610 assert!(shared_staging_agent(&cfg, &[]).is_none());
14611 assert!(
14612 shared_staging_agent(
14613 &cfg,
14614 &[
14615 "https://one.example.com/a?sig=1",
14616 "https://two.example.com/b?sig=2",
14617 ]
14618 )
14619 .is_none(),
14620 "two authorities must not share a pinned pool"
14621 );
14622 assert!(
14623 shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
14624 "an unsafe object-store URL must not produce an agent"
14625 );
14626 assert!(
14627 shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
14628 "credentials in the URL must not produce an agent"
14629 );
14630 }
14631
14632 #[test]
14633 fn a_staged_change_states_only_operations_and_blobs() {
14634 let operations = vec![json!({
14638 "op": "put",
14639 "path": "records/a.md",
14640 "blob": "a".repeat(64),
14641 "bytes": 3,
14642 })];
14643 let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
14644 let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
14645 let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
14646 let keys: Vec<&str> = parsed
14647 .as_object()
14648 .expect("manifest is an object")
14649 .keys()
14650 .map(String::as_str)
14651 .collect();
14652 assert_eq!(keys, ["blobs", "operations"]);
14653 assert_eq!(parsed["operations"], Value::Array(operations));
14654 assert_eq!(parsed["blobs"], blobs);
14655 }
14656
14657 #[test]
14658 fn a_staged_push_signs_the_change_not_the_transport() {
14659 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14664 let staged = json!({
14665 "mutation_id": "dbmd-1",
14666 "rebase": "strict",
14667 "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
14668 });
14669 let view = v2_signed_request_view(&staged, &operations);
14670 assert_eq!(view["operations"], Value::Array(operations.clone()));
14671 assert!(view.get("staged_change").is_none());
14672 assert_eq!(view["mutation_id"], staged["mutation_id"]);
14673
14674 let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
14675 assert_eq!(v2_signed_request_view(&inline, &[]), inline);
14676 }
14677
14678 #[test]
14679 fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
14680 let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
14681 let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
14682 .expect_err("an oversized change must not be staged");
14683 assert!(
14684 matches!(error, LinkError::PushTooLarge { .. }),
14685 "expected a size refusal, got {error:?}"
14686 );
14687 }
14688
14689 #[test]
14690 fn a_push_that_fits_the_request_is_left_inline() {
14691 let cfg = HubConfig {
14695 hub: "http://127.0.0.1:9".to_string(),
14696 key: Some("k".to_string()),
14697 agent_key: None,
14698 brain_key: None,
14699 state_dir: PathBuf::from("."),
14700 store_selected: false,
14701 };
14702 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14703 let mut body = json!({
14704 "mutation_id": "dbmd-1",
14705 "operations": operations,
14706 "blobs": [],
14707 });
14708 stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
14709 assert!(body.get("staged_change").is_none());
14710 assert_eq!(body["operations"], Value::Array(operations));
14711 }
14712
14713 #[test]
14714 fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
14715 let declarations: Vec<Value> = (0..2_000)
14719 .map(|index| {
14720 json!({
14721 "sha256": "a".repeat(64),
14722 "bytes": 10,
14723 "coordinates": (0..24)
14724 .map(|slot| format!(
14725 "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
14726 ))
14727 .collect::<Vec<_>>(),
14728 })
14729 })
14730 .collect();
14731 let batches = batch_upload_declarations(declarations);
14732 assert!(
14733 batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
14734 "wide coordinate sets must bound the batch by size"
14735 );
14736 for batch in &batches {
14737 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14738 .expect("batch serializes")
14739 .len();
14740 assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
14741 }
14742 }
14743
14744 #[test]
14745 fn a_small_push_still_rides_exactly_one_request() {
14746 let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
14747 assert_eq!(batch_upload_declarations(declarations).len(), 1);
14748 assert!(batch_upload_declarations(Vec::new()).is_empty());
14749 }
14750
14751 #[test]
14752 fn exact_source_move_becomes_one_provenance_preserving_rename() {
14753 let hash = "a".repeat(64);
14754 let operations = vec![
14755 json!({
14756 "op": "put",
14757 "path": "sources/curated/item.md",
14758 "expected": { "kind": "absent" },
14759 "blob": hash,
14760 "bytes": 19,
14761 }),
14762 json!({
14763 "op": "delete",
14764 "path": "sources/inbox/item.md",
14765 "expected": { "kind": "blob", "hash": hash },
14766 }),
14767 ];
14768
14769 assert_eq!(
14770 infer_exact_source_promotions(operations),
14771 vec![json!({
14772 "op": "rename",
14773 "from": "sources/inbox/item.md",
14774 "to": "sources/curated/item.md",
14775 "expected_from": { "kind": "blob", "hash": hash },
14776 "expected_to": { "kind": "absent" },
14777 "blob": hash,
14778 "bytes": 19,
14779 })]
14780 );
14781 }
14782
14783 #[test]
14784 fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
14785 let hash = "b".repeat(64);
14786 let operations = vec![
14787 json!({
14788 "op": "delete",
14789 "path": "sources/inbox/a.md",
14790 "expected": { "kind": "blob", "hash": hash },
14791 }),
14792 json!({
14793 "op": "delete",
14794 "path": "sources/inbox/b.md",
14795 "expected": { "kind": "blob", "hash": hash },
14796 }),
14797 json!({
14798 "op": "put",
14799 "path": "sources/curated/item.md",
14800 "expected": { "kind": "absent" },
14801 "blob": hash,
14802 "bytes": 19,
14803 }),
14804 ];
14805
14806 assert_eq!(
14807 infer_exact_source_promotions(operations.clone()),
14808 operations,
14809 "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
14810 );
14811 }
14812
14813 #[test]
14814 fn accepted_source_promotion_advances_the_local_baseline_exactly() {
14815 let hash = "c".repeat(64);
14816 let mut candidate = std::collections::BTreeMap::from([(
14817 "sources/inbox/item.md".to_string(),
14818 V2BaselineFile {
14819 sha256: hash.clone(),
14820 bytes: 19,
14821 proof: None,
14822 },
14823 )]);
14824 let mut candidate_assets = std::collections::BTreeMap::new();
14825 let operations = vec![
14826 json!({
14827 "op": "rename",
14828 "from": "sources/inbox/item.md",
14829 "to": "sources/curated/item.md",
14830 "expected_from": { "kind": "blob", "hash": hash },
14831 "expected_to": { "kind": "absent" },
14832 "blob": hash,
14833 "bytes": 19,
14834 }),
14835 json!({
14836 "op": "put",
14837 "path": "records/rsvps/item.md",
14838 "expected": { "kind": "absent" },
14839 "blob": "d".repeat(64),
14840 "bytes": 23,
14841 }),
14842 ];
14843
14844 assert!(!apply_generated_v2_operations(
14845 &operations,
14846 &std::collections::BTreeMap::new(),
14847 &mut candidate,
14848 &mut candidate_assets,
14849 )
14850 .unwrap());
14851 assert!(!candidate.contains_key("sources/inbox/item.md"));
14852 assert_eq!(
14853 candidate
14854 .get("sources/curated/item.md")
14855 .map(|file| (&file.sha256, file.bytes)),
14856 Some((&hash, 19))
14857 );
14858 assert_eq!(
14859 candidate
14860 .get("records/rsvps/item.md")
14861 .map(|file| (file.sha256.as_str(), file.bytes)),
14862 Some((
14863 "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
14864 23
14865 ))
14866 );
14867 }
14868
14869 fn merge_fixture(
14870 base: Option<&str>,
14871 remote: Option<&str>,
14872 local: Option<&str>,
14873 keep_local: bool,
14874 ) -> V2PulledMerge<String> {
14875 let map = |value: Option<&str>| {
14876 value
14877 .map(|value| [("records/a.md".to_string(), value.to_string())])
14878 .into_iter()
14879 .flatten()
14880 .collect::<std::collections::BTreeMap<_, _>>()
14881 };
14882 merge_v2_pulled_records(
14883 &map(base),
14884 &map(remote),
14885 &map(local),
14886 |value, _| value.clone(),
14887 |value, _| value.clone(),
14888 |_| keep_local,
14889 )
14890 }
14891
14892 #[test]
14893 fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
14894 let path = "records/a.md".to_string();
14895
14896 let local_add = merge_fixture(None, None, Some("local"), false);
14897 assert_eq!(
14898 local_add.records.get(&path).map(String::as_str),
14899 Some("local")
14900 );
14901 assert!(local_add.accept_remote.is_empty());
14902 assert!(local_add.conflicts.is_empty());
14903
14904 let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
14905 assert_eq!(
14906 local_edit.records.get(&path).map(String::as_str),
14907 Some("local")
14908 );
14909 assert!(local_edit.accept_remote.is_empty());
14910 assert!(local_edit.conflicts.is_empty());
14911
14912 let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
14913 assert!(!local_delete.records.contains_key(&path));
14914 assert!(local_delete.accept_remote.is_empty());
14915 assert!(local_delete.conflicts.is_empty());
14916
14917 let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
14918 assert_eq!(
14919 remote_edit.records.get(&path).map(String::as_str),
14920 Some("remote")
14921 );
14922 assert!(remote_edit.accept_remote.contains(&path));
14923 assert!(remote_edit.conflicts.is_empty());
14924
14925 let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
14926 assert!(!remote_delete.records.contains_key(&path));
14927 assert!(remote_delete.accept_remote.contains(&path));
14928 assert!(remote_delete.conflicts.is_empty());
14929
14930 let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
14931 assert_eq!(
14932 same_edit.records.get(&path).map(String::as_str),
14933 Some("same")
14934 );
14935 assert!(same_edit.accept_remote.contains(&path));
14936 assert!(same_edit.conflicts.is_empty());
14937
14938 let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
14939 assert_eq!(conflict.conflicts, vec![path.clone()]);
14940 assert_eq!(
14941 conflict.records.get(&path).map(String::as_str),
14942 Some("local")
14943 );
14944 assert!(conflict.accept_remote.is_empty());
14945
14946 let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
14947 assert_eq!(
14948 kept_home.records.get(&path).map(String::as_str),
14949 Some("local")
14950 );
14951 assert!(kept_home.accept_remote.is_empty());
14952 assert!(kept_home.conflicts.is_empty());
14953 }
14954
14955 #[test]
14956 fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
14957 let path = "sources/report.pdf";
14958 let record = crate::AssetRecord {
14959 path: path.to_string(),
14960 sha256: "a".repeat(64),
14961 bytes: 42,
14962 media_type: "application/pdf".to_string(),
14963 wrappers: vec!["gzip".to_string()],
14964 required: true,
14965 };
14966 let mut remote = V2BaselineAsset {
14967 blob_sha256: record.sha256.clone(),
14968 bytes: record.bytes,
14969 media_type: record.media_type.clone(),
14970 wrappers: record.wrappers.clone(),
14971 required: record.required,
14972 disposition: "withheld".to_string(),
14973 leaf_hash: "b".repeat(64),
14974 };
14975
14976 assert!(v2_asset_resumes_hosting(
14977 Some(&remote),
14978 path,
14979 &record,
14980 "hosted"
14981 ));
14982 assert!(!v2_asset_resumes_hosting(
14983 Some(&remote),
14984 path,
14985 &record,
14986 "withheld"
14987 ));
14988
14989 remote.disposition = "hosted".to_string();
14990 assert!(!v2_asset_resumes_hosting(
14991 Some(&remote),
14992 path,
14993 &record,
14994 "hosted"
14995 ));
14996
14997 remote.disposition = "withheld".to_string();
14998 remote.blob_sha256 = "c".repeat(64);
14999 assert!(!v2_asset_resumes_hosting(
15000 Some(&remote),
15001 path,
15002 &record,
15003 "hosted"
15004 ));
15005 assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
15006 }
15007
15008 #[test]
15009 fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
15010 let path = "records/team/alpha.md".to_string();
15011 let deleted_path = "records/team/deleted.md".to_string();
15012 let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
15013 sha256,
15014 bytes,
15015 file: None,
15016 };
15017 let files = vec![
15018 V2ConflictFile {
15019 path: path.clone(),
15020 base: coordinate(None, None),
15021 local: coordinate(Some("b".repeat(64)), Some(7)),
15022 remote: coordinate(Some("a".repeat(64)), Some(5)),
15023 },
15024 V2ConflictFile {
15025 path: deleted_path.clone(),
15026 base: coordinate(Some("c".repeat(64)), Some(9)),
15027 local: coordinate(Some("d".repeat(64)), Some(11)),
15028 remote: coordinate(None, None),
15029 },
15030 ];
15031 let proven = V2BaselineFile {
15032 sha256: "a".repeat(64),
15033 bytes: 5,
15034 proof: None,
15035 };
15036 let current = [(path.clone(), proven.clone())]
15037 .into_iter()
15038 .collect::<std::collections::BTreeMap<_, _>>();
15039
15040 let (selected, deleted) = v2_take_remote_selection(&files, ¤t).unwrap();
15041 assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
15042 assert_eq!(deleted, vec![deleted_path.clone()]);
15043
15044 let changed = [(
15045 path.clone(),
15046 V2BaselineFile {
15047 sha256: "e".repeat(64),
15048 bytes: 5,
15049 proof: None,
15050 },
15051 )]
15052 .into_iter()
15053 .collect::<std::collections::BTreeMap<_, _>>();
15054 assert!(v2_take_remote_selection(&files, &changed).is_err());
15055
15056 let resurrected = [
15057 (path, proven),
15058 (
15059 deleted_path,
15060 V2BaselineFile {
15061 sha256: "f".repeat(64),
15062 bytes: 13,
15063 proof: None,
15064 },
15065 ),
15066 ]
15067 .into_iter()
15068 .collect::<std::collections::BTreeMap<_, _>>();
15069 assert!(v2_take_remote_selection(&files, &resurrected).is_err());
15070 }
15071
15072 #[cfg(target_os = "linux")]
15073 #[test]
15074 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
15075 use std::os::fd::AsRawFd as _;
15076
15077 let sandbox = tempfile::TempDir::new().unwrap();
15078 let parent = std::fs::File::open(sandbox.path()).unwrap();
15079 let stage = std::ffi::CString::new("stage").unwrap();
15080 let destination = std::ffi::CString::new("brain").unwrap();
15081
15082 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15083 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
15084 install_stage_at(
15085 parent.as_raw_fd(),
15086 stage.as_c_str(),
15087 destination.as_c_str(),
15088 false,
15089 )
15090 .unwrap();
15091 assert!(!sandbox.path().join("stage").exists());
15092 assert_eq!(
15093 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15094 b"created"
15095 );
15096
15097 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15098 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
15099 install_stage_at(
15100 parent.as_raw_fd(),
15101 stage.as_c_str(),
15102 destination.as_c_str(),
15103 true,
15104 )
15105 .unwrap();
15106 assert_eq!(
15107 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15108 b"replacement"
15109 );
15110 assert_eq!(
15111 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
15112 b"created",
15113 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
15114 );
15115 }
15116
15117 struct SignedRemoteFixture {
15118 card: String,
15119 feed: String,
15120 key: AgentSigningKey,
15121 identity: FeedIdentity,
15122 }
15123
15124 fn signed_remote_fixture() -> SignedRemoteFixture {
15125 let rng = ring::rand::SystemRandom::new();
15126 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15127 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15128 let (public_key, multikey) = public_identity_for(&pair);
15129 let identity = FeedIdentity {
15130 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15131 public_key_spki: public_key.clone(),
15132 previous: Vec::new(),
15133 rotations: Vec::new(),
15134 };
15135 let mut entry = FeedEntry {
15136 v: 1,
15137 seq: 1,
15138 ts: "2026-07-30T12:00:00.000Z".to_string(),
15139 brain: multikey.clone(),
15140 public_key: public_key.clone(),
15141 kind: "push".to_string(),
15142 op: "snapshot".to_string(),
15143 pack_sha256: "a".repeat(64),
15144 files: Vec::new(),
15145 removed: Vec::new(),
15146 prev_entry_hash: None,
15147 sig: String::new(),
15148 };
15149 let unsigned = UnsignedFeedEntry {
15150 v: entry.v,
15151 seq: entry.seq,
15152 ts: &entry.ts,
15153 brain: &entry.brain,
15154 public_key: &entry.public_key,
15155 kind: &entry.kind,
15156 op: &entry.op,
15157 pack_sha256: &entry.pack_sha256,
15158 files: &entry.files,
15159 removed: &entry.removed,
15160 prev_entry_hash: &entry.prev_entry_hash,
15161 };
15162 entry.sig =
15163 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15164 let mut exact = serde_json::to_vec(&entry).unwrap();
15165 exact.push(b'\n');
15166 let hash = content_sha256(&exact);
15167 let card = json!({
15168 "id": TEST_BRAIN_ID,
15169 "headSeq": 1,
15170 "feedHash": hash,
15171 "identity": identity.clone(),
15172 })
15173 .to_string();
15174 let feed = json!({
15175 "headSeq": 1,
15176 "feedHash": hash,
15177 "identity": identity.clone(),
15178 "entries": [{"hash": hash, "entry": entry}],
15179 "scopeLimited": false,
15180 })
15181 .to_string();
15182 SignedRemoteFixture {
15183 card,
15184 feed,
15185 key: AgentSigningKey {
15186 pkcs8: pkcs8.as_ref().to_vec(),
15187 multikey,
15188 public_key_spki: public_key,
15189 },
15190 identity,
15191 }
15192 }
15193
15194 #[test]
15195 fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
15196 let file = |path: &str, byte: char| FeedFile {
15197 path: path.to_string(),
15198 sha256: byte.to_string().repeat(64),
15199 bytes: 1,
15200 };
15201 let a0 = file("records/a.md", 'a');
15202 let a1 = file("records/a.md", 'b');
15203 let stable = file("records/stable.md", 'c');
15204 let added = file("records/added.md", 'd');
15205 let removed_file = file("records/removed.md", 'e');
15206 let previous = vec![a0, stable.clone(), removed_file.clone()];
15207 let resulting = vec![a1.clone(), stable.clone(), added.clone()];
15208 let removed = vec![removed_file.path.clone()];
15209
15210 assert_eq!(
15211 verify_v1_manifest_disclosure(
15212 "edit",
15213 &previous,
15214 &resulting,
15215 &[a1.clone(), added.clone()],
15216 &removed,
15217 ),
15218 Ok(())
15219 );
15220 assert_eq!(
15221 verify_v1_manifest_disclosure(
15222 "edit",
15223 &previous,
15224 &resulting,
15225 &[stable.clone(), added.clone(), a1.clone()],
15226 &removed,
15227 ),
15228 Ok(())
15229 );
15230 assert_eq!(
15231 verify_v1_manifest_disclosure(
15232 "edit",
15233 &previous,
15234 &resulting,
15235 std::slice::from_ref(&added),
15236 &removed,
15237 ),
15238 Err(V1DisclosureError::EditMissingChange)
15239 );
15240 assert_eq!(
15241 verify_v1_manifest_disclosure(
15242 "edit",
15243 &previous,
15244 &resulting,
15245 &[file("records/a.md", 'f'), added.clone()],
15246 &removed,
15247 ),
15248 Err(V1DisclosureError::EditFalseFile)
15249 );
15250 assert_eq!(
15251 verify_v1_manifest_disclosure(
15252 "edit",
15253 &previous,
15254 &resulting,
15255 &[a1.clone(), added.clone()],
15256 &[],
15257 ),
15258 Err(V1DisclosureError::RemovedMismatch)
15259 );
15260 assert_eq!(
15261 verify_v1_manifest_disclosure(
15262 "push",
15263 &previous,
15264 &resulting,
15265 &[added.clone(), stable, a1],
15266 &removed,
15267 ),
15268 Ok(())
15269 );
15270 assert_eq!(
15271 verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
15272 Err(V1DisclosureError::PushManifestMismatch)
15273 );
15274 }
15275
15276 #[test]
15277 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
15278 let fixture = signed_remote_fixture();
15279 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
15280 let item = feed["entries"][0].to_string();
15281 let oversized_page = format!(
15282 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
15283 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
15284 .collect::<Vec<_>>()
15285 .join(",")
15286 );
15287 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
15288
15289 let oversized_identity = format!(
15290 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
15291 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
15292 .collect::<Vec<_>>()
15293 .join(",")
15294 );
15295 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
15296
15297 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
15298 let oversized_entry = format!(
15299 "{{\"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\"}}",
15300 "a".repeat(64),
15301 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
15302 .collect::<Vec<_>>()
15303 .join(",")
15304 );
15305 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
15306 }
15307
15308 #[test]
15309 fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
15310 let id = "01arz3ndektsv4rrffq69g5fav";
15311 let digest = "a".repeat(64);
15312 assert_eq!(
15313 V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
15314 V2BulkConfirmation {
15315 id: id.to_string(),
15316 digest,
15317 }
15318 );
15319 for invalid in [
15320 "",
15321 "01arz3ndektsv4rrffq69g5fav",
15322 "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15323 "01arz3ndektsv4rrffq69g5fav:ABCDEF",
15324 "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15325 ] {
15326 assert!(matches!(
15327 V2BulkConfirmation::parse(invalid),
15328 Err(LinkError::InvalidPack { .. })
15329 ));
15330 }
15331 }
15332
15333 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
15334 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15335 use std::net::TcpListener;
15336
15337 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15338 let url = format!("http://{}", listener.local_addr().unwrap());
15339 let handle = std::thread::spawn(move || {
15340 for (status, body) in responses {
15341 let (stream, _) = listener.accept().unwrap();
15342 let mut reader = BufReader::new(stream);
15343 let mut line = String::new();
15344 reader.read_line(&mut line).unwrap();
15345 let mut content_length = 0usize;
15346 loop {
15347 line.clear();
15348 reader.read_line(&mut line).unwrap();
15349 if line == "\r\n" || line == "\n" || line.is_empty() {
15350 break;
15351 }
15352 if let Some((name, value)) = line.split_once(':') {
15353 if name.eq_ignore_ascii_case("content-length") {
15354 content_length = value.trim().parse().unwrap();
15355 }
15356 }
15357 }
15358 let mut request_body = vec![0_u8; content_length];
15359 reader.read_exact(&mut request_body).unwrap();
15360 let response = format!(
15361 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15362 body.len()
15363 );
15364 reader.get_mut().write_all(response.as_bytes()).unwrap();
15365 }
15366 });
15367 (url, handle)
15368 }
15369
15370 fn routed_json_hub(
15371 requests: usize,
15372 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
15373 ) -> (String, std::thread::JoinHandle<()>) {
15374 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15375 use std::net::TcpListener;
15376
15377 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15378 let url = format!("http://{}", listener.local_addr().unwrap());
15379 let handle = std::thread::spawn(move || {
15380 for _ in 0..requests {
15381 let (stream, _) = listener.accept().unwrap();
15382 let mut reader = BufReader::new(stream);
15383 let mut line = String::new();
15384 reader.read_line(&mut line).unwrap();
15385 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
15386 let mut content_length = 0usize;
15387 loop {
15388 line.clear();
15389 reader.read_line(&mut line).unwrap();
15390 if line == "\r\n" || line == "\n" || line.is_empty() {
15391 break;
15392 }
15393 if let Some((name, value)) = line.split_once(':') {
15394 if name.eq_ignore_ascii_case("content-length") {
15395 content_length = value.trim().parse().unwrap();
15396 }
15397 }
15398 }
15399 let mut request_body = vec![0_u8; content_length];
15400 reader.read_exact(&mut request_body).unwrap();
15401 let (status, body) = respond(&path);
15402 let response = format!(
15403 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15404 body.len()
15405 );
15406 reader.get_mut().write_all(response.as_bytes()).unwrap();
15407 }
15408 });
15409 (url, handle)
15410 }
15411
15412 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
15413 HubConfig {
15414 hub,
15415 key: Some("test-key".to_string()),
15416 agent_key: None,
15417 brain_key: None,
15418 state_dir,
15419 store_selected: false,
15420 }
15421 }
15422
15423 #[test]
15424 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
15425 use ring::signature::KeyPair as _;
15426
15427 let rng = ring::rand::SystemRandom::new();
15428 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15429 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15430 let (spki, multikey) = public_identity_for(&pair);
15431 let key = AgentSigningKey {
15432 pkcs8: pkcs8.as_ref().to_vec(),
15433 multikey,
15434 public_key_spki: spki,
15435 };
15436 let header = linkmd_sig_header(
15437 &key,
15438 "https://hub-a.example",
15439 "post",
15440 "/api/hub/brains/brain/push?mode=exact",
15441 Some("{\"ok\":true}"),
15442 )
15443 .unwrap();
15444 assert!(header.starts_with("LinkMD-Sig v2,"));
15445 let ts = header
15446 .split(",ts=")
15447 .nth(1)
15448 .unwrap()
15449 .split(',')
15450 .next()
15451 .unwrap();
15452 let signature = URL_SAFE_NO_PAD
15453 .decode(header.rsplit(",sig=").next().unwrap())
15454 .unwrap();
15455 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
15456 let accepted = format!(
15457 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15458 );
15459 let replayed = format!(
15460 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15461 );
15462 let public = pair.public_key().as_ref();
15463 assert!(UnparsedPublicKey::new(&ED25519, public)
15464 .verify(accepted.as_bytes(), &signature)
15465 .is_ok());
15466 assert!(
15467 UnparsedPublicKey::new(&ED25519, public)
15468 .verify(replayed.as_bytes(), &signature)
15469 .is_err(),
15470 "a proof captured at hub A must not authenticate at hub B"
15471 );
15472 }
15473
15474 #[test]
15475 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
15476 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15477 let card = json!({
15478 "id": other,
15479 "headSeq": 0,
15480 "identity": signed_remote_fixture().identity,
15481 })
15482 .to_string();
15483 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15484 let state = tempfile::tempdir().unwrap();
15485 let cfg = test_hub_config(hub, state.path().to_path_buf());
15486 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15487 assert!(
15488 error.contains("differs from the explicitly requested"),
15489 "{error}"
15490 );
15491 server.join().unwrap();
15492 }
15493
15494 #[test]
15495 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
15496 let first = signed_remote_fixture().identity;
15497 let second = signed_remote_fixture().identity;
15498 let card = |identity: FeedIdentity| {
15499 json!({
15500 "id": TEST_BRAIN_ID,
15501 "headSeq": 0,
15502 "identity": identity,
15503 })
15504 .to_string()
15505 };
15506 let (hub, server) = scripted_json_hub(vec![
15507 (404, "{}".to_string()),
15508 (200, card(first)),
15509 (404, "{}".to_string()),
15510 (200, card(second)),
15511 ]);
15512 let state = tempfile::tempdir().unwrap();
15513 let cfg = test_hub_config(hub, state.path().to_path_buf());
15514 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15515 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15516 assert!(
15517 error.contains("pinned anchor") || error.contains("forked away"),
15518 "{error}"
15519 );
15520 server.join().unwrap();
15521 }
15522
15523 #[test]
15524 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
15525 let old = signed_remote_fixture();
15526 let new = signed_remote_fixture();
15527 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
15528 let unsigned = serde_json::to_string(&UnsignedRotation {
15529 v: 1,
15530 op: "rotate",
15531 brain: &old.key.multikey,
15532 public_key: &old.key.public_key_spki,
15533 new_brain: &new.key.multikey,
15534 new_public_key: &new.key.public_key_spki,
15535 prior_head_seq: 1,
15536 prior_feed_hash: Some(&"a".repeat(64)),
15537 ts: "2026-07-30T12:00:00.000Z".to_string(),
15538 })
15539 .unwrap();
15540 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
15541 let rotation = format!(
15542 "{},\"sig\":\"{}\"}}",
15543 &unsigned[..unsigned.len() - 1],
15544 signature
15545 );
15546 let identity = FeedIdentity {
15547 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
15548 public_key_spki: new.key.public_key_spki,
15549 previous: vec![PreviousIdentity {
15550 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
15551 public_key_spki: old.key.public_key_spki,
15552 }],
15553 rotations: vec![rotation],
15554 };
15555 let card = json!({
15556 "id": TEST_BRAIN_ID,
15557 "headSeq": 0,
15558 "feedHash": null,
15559 "identity": identity,
15560 })
15561 .to_string();
15562 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15563 let state = tempfile::tempdir().unwrap();
15564 let cfg = test_hub_config(hub, state.path().to_path_buf());
15565 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15566 assert!(
15567 error.contains("rotation claims a feed boundary beyond the advertised head"),
15568 "{error}"
15569 );
15570 assert!(
15571 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
15572 "an inconsistent empty-head identity must not become the TOFU checkpoint"
15573 );
15574 server.join().unwrap();
15575 }
15576
15577 #[test]
15578 fn trust_checkpoint_rejects_a_later_fork() {
15579 let fixture = signed_remote_fixture();
15580 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
15581 fork["feedHash"] = Value::String("b".repeat(64));
15582 let (hub, server) = scripted_json_hub(vec![
15583 (404, "{}".to_string()),
15584 (200, fixture.card),
15585 (200, fixture.feed),
15586 (404, "{}".to_string()),
15587 (200, fork.to_string()),
15588 ]);
15589 let state = tempfile::tempdir().unwrap();
15590 let cfg = test_hub_config(hub, state.path().to_path_buf());
15591 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15592 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
15593 server.join().unwrap();
15594 }
15595
15596 #[test]
15597 fn alias_and_canonical_id_share_one_identity_checkpoint() {
15598 let trusted = signed_remote_fixture();
15599 let attacker = signed_remote_fixture();
15600 let (hub, server) = scripted_json_hub(vec![
15601 (404, "{}".to_string()),
15602 (200, trusted.card),
15603 (200, trusted.feed),
15604 (404, "{}".to_string()),
15605 (200, attacker.card),
15606 ]);
15607 let state = tempfile::tempdir().unwrap();
15608 let cfg = test_hub_config(hub, state.path().to_path_buf());
15609 assert!(head(&cfg, "trusted-slug").unwrap().verified);
15610 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15611 assert!(
15612 error.contains("equivocation")
15613 || error.contains("pinned")
15614 || error.contains("identity"),
15615 "{error}"
15616 );
15617 server.join().unwrap();
15618 }
15619
15620 #[test]
15621 fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
15622 let state = tempfile::tempdir().unwrap();
15623 let cfg = test_hub_config(
15624 "https://hub.example".to_string(),
15625 state.path().to_path_buf(),
15626 );
15627 let directory = open_trust_dir(&cfg).unwrap();
15628 let old = TEST_BRAIN_ID;
15629 let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15630 save_alias_in(
15631 &cfg,
15632 &directory,
15633 &AliasBinding {
15634 v: 1,
15635 origin: normalized_origin(&cfg.hub).unwrap(),
15636 requested: "company-brain".to_string(),
15637 brain: old.to_string(),
15638 home: Some("company-brain".to_string()),
15639 },
15640 )
15641 .unwrap();
15642
15643 let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
15644 assert!(matches!(
15645 error,
15646 LinkError::AliasRebindRequired {
15647 alias,
15648 from,
15649 to
15650 } if alias == "company-brain" && from == old && to == new
15651 ));
15652 let unchanged = load_alias_in(&cfg, &directory, "company-brain")
15653 .unwrap()
15654 .unwrap();
15655 assert_eq!(unchanged.brain, old);
15656 assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
15657 }
15658
15659 #[test]
15660 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
15661 let alpha = signed_remote_fixture();
15662 let beta = signed_remote_fixture();
15663 let alpha_card = alpha.card.clone();
15664 let alpha_feed = alpha.feed.clone();
15665 let beta_card = beta.card.clone();
15666 let beta_feed = beta.feed.clone();
15667 let (hub, server) = routed_json_hub(5, move |path| {
15668 if path.ends_with("/v2/head") {
15669 (404, "{}".to_string())
15670 } else if path.contains("/alpha/feed?") {
15671 (200, alpha_feed.clone())
15672 } else if path.contains("/beta/feed?") {
15673 (200, beta_feed.clone())
15674 } else if path.ends_with("/alpha") {
15675 (200, alpha_card.clone())
15676 } else if path.ends_with("/beta") {
15677 (200, beta_card.clone())
15678 } else {
15679 (500, r#"{"error":"unexpected path"}"#.to_string())
15680 }
15681 });
15682 let state = tempfile::tempdir().unwrap();
15683 let cfg = test_hub_config(hub, state.path().to_path_buf());
15684 let alpha_cfg = cfg.clone();
15685 let beta_cfg = cfg;
15686 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
15687 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
15688 let results = [first.join().unwrap(), second.join().unwrap()];
15689 assert_eq!(
15690 results.iter().filter(|result| result.is_ok()).count(),
15691 1,
15692 "only one alias identity may establish canonical TOFU: {results:?}"
15693 );
15694 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
15695 server.join().unwrap();
15696 }
15697
15698 #[cfg(unix)]
15699 #[test]
15700 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
15701 use std::os::unix::fs::symlink;
15702
15703 let fixture = signed_remote_fixture();
15704 let card = json!({
15705 "id": TEST_BRAIN_ID,
15706 "headSeq": 0,
15707 "feedHash": Value::Null,
15708 "identity": fixture.identity,
15709 })
15710 .to_string();
15711 let work = tempfile::tempdir().unwrap();
15712 let outside = tempfile::tempdir().unwrap();
15713 let state = work.path().join("state");
15714 let moved = work.path().join("state-held");
15715 let swap_state = state.clone();
15716 let swap_moved = moved.clone();
15717 let outside_path = outside.path().to_path_buf();
15718 let (hub, server) = routed_json_hub(1, move |_| {
15719 std::fs::rename(&swap_state, &swap_moved).unwrap();
15721 symlink(&outside_path, &swap_state).unwrap();
15722 (200, card.clone())
15723 });
15724 let cfg = test_hub_config(hub, state);
15725
15726 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
15727 assert_eq!(verified.head.seq, 0);
15728 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
15729 assert!(std::fs::read_dir(moved.join("trust"))
15730 .unwrap()
15731 .flatten()
15732 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
15733 server.join().unwrap();
15734 }
15735
15736 #[test]
15737 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
15738 let remote = signed_remote_fixture();
15739 let unrelated = signed_remote_fixture().key;
15740 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
15741 let state = tempfile::tempdir().unwrap();
15742 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
15743 cfg.brain_key = Some(unrelated);
15744 let error = sync_push(
15745 &cfg,
15746 TEST_BRAIN_ID,
15747 &[("DB.md".to_string(), "signed local content".to_string())],
15748 )
15749 .unwrap_err()
15750 .to_string();
15751 assert!(
15752 error.contains("not the verified current brain identity"),
15753 "{error}"
15754 );
15755 server.join().unwrap();
15756 }
15757
15758 #[test]
15759 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
15760 let remote = signed_remote_fixture();
15761 let new = signed_remote_fixture().key;
15762 let state = tempfile::tempdir().unwrap();
15763 let new_file = state.path().join("new.key");
15764 std::fs::write(
15765 &new_file,
15766 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
15767 )
15768 .unwrap();
15769 #[cfg(unix)]
15770 {
15771 use std::os::unix::fs::PermissionsExt as _;
15772 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
15773 }
15774 let forged = json!({
15775 "brain": TEST_BRAIN_ID,
15776 "identity": {
15777 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
15778 "publicKeySpki": new.public_key_spki,
15779 }
15780 })
15781 .to_string();
15782 let (hub, server) = scripted_json_hub(vec![
15783 (404, "{}".to_string()),
15784 (200, remote.card.clone()),
15785 (200, remote.feed.clone()),
15786 (200, forged),
15787 (200, remote.card),
15788 (200, remote.feed),
15789 ]);
15790 let cfg = test_hub_config(hub, state.path().to_path_buf());
15791 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
15792 .unwrap_err()
15793 .to_string();
15794 assert!(
15795 error.contains("without committing the verified new identity"),
15796 "{error}"
15797 );
15798 server.join().unwrap();
15799 }
15800
15801 #[test]
15802 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
15803 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15804 let raw = format!(
15805 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
15806 );
15807 let pack = build_store_pack(&[
15808 (
15809 "DB.md".to_string(),
15810 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
15811 ),
15812 ("records/clients/truth.md".to_string(), raw.clone()),
15813 ])
15814 .unwrap();
15815 let by_id = resolve_from_verified_pack(
15816 "01j5qc3v9k4ym8rwbn2tqe6f7d",
15817 &AddressTarget::Id(record_id.to_string()),
15818 pack.clone(),
15819 )
15820 .unwrap();
15821 assert_eq!(by_id["document"]["summary"], "Signed truth");
15822 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
15823 assert_eq!(
15824 by_id["document"]["contentSha"],
15825 content_sha256(raw.as_bytes())
15826 );
15827
15828 let by_path = resolve_from_verified_pack(
15829 "01j5qc3v9k4ym8rwbn2tqe6f7d",
15830 &AddressTarget::Path("records/clients/truth.md".to_string()),
15831 pack,
15832 )
15833 .unwrap();
15834 assert_eq!(by_path["document"]["id"], record_id);
15835 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
15836
15837 let wrong_id = resolve_from_verified_record_bytes(
15838 TEST_BRAIN_ID,
15839 &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
15840 "records/clients/truth.md".to_string(),
15841 raw.as_bytes().to_vec(),
15842 )
15843 .unwrap_err()
15844 .to_string();
15845 assert!(wrong_id.contains("id differs"), "{wrong_id}");
15846
15847 let wrong_path = resolve_from_verified_record_bytes(
15848 TEST_BRAIN_ID,
15849 &AddressTarget::Path("records/clients/other.md".to_string()),
15850 "records/clients/truth.md".to_string(),
15851 raw.into_bytes(),
15852 )
15853 .unwrap_err()
15854 .to_string();
15855 assert!(wrong_path.contains("path differs"), "{wrong_path}");
15856 }
15857
15858 #[test]
15859 fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
15860 let path = "records/clients/truth.md";
15861 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15862 let raw = format!(
15863 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
15864 );
15865 let sha256 = content_sha256(raw.as_bytes());
15866 let mut nonce = 0_u128;
15867 let tree = crate::linkmd_v2::build_content_tree(
15868 &[crate::linkmd_v2::ContentFile {
15869 path: path.to_string(),
15870 blob_hash: sha256.clone(),
15871 bytes: raw.len() as u64,
15872 }],
15873 None,
15874 &mut || {
15875 nonce += 1;
15876 format!("{nonce:032x}")
15877 },
15878 )
15879 .unwrap();
15880 let root = tree.root.clone().unwrap();
15881 let mut directory_root = root.clone();
15882 let mut proof = Vec::new();
15883 for component in path.split('/') {
15884 let inclusion =
15885 crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
15886 let child = match &inclusion {
15887 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
15888 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
15889 panic!("fixture path must have an inclusion proof")
15890 }
15891 };
15892 proof.push(json!({
15893 "directory_root": directory_root,
15894 "component": component,
15895 "proof": inclusion,
15896 }));
15897 directory_root = child;
15898 }
15899 let commit_hash = "c".repeat(64);
15900 let pointer = V2PointerBody {
15901 v: 2,
15902 brain: TEST_BRAIN_ID.to_string(),
15903 seq: 1,
15904 commit_hash: commit_hash.clone(),
15905 feed_hash: "f".repeat(64),
15906 content_root: Some(root.clone()),
15907 asset_root: None,
15908 materializer: "dbmd-projection-v1".to_string(),
15909 signer_epoch: 1,
15910 control_revision: "d".repeat(64),
15911 backup_preparation: "e".repeat(64),
15912 prior_pointer_hash: None,
15913 signed_at: "2026-08-21T12:00:00.000Z".to_string(),
15914 };
15915 let manifest = json!({
15916 "v": 2,
15917 "commit": commit_hash,
15918 "content_root": root,
15919 "files": [{
15920 "path": path,
15921 "sha256": sha256,
15922 "bytes": raw.len(),
15923 "proof": proof,
15924 }],
15925 "next_cursor": Value::Null,
15926 })
15927 .to_string();
15928
15929 let path_manifest = manifest.clone();
15930 let (hub, server) = routed_json_hub(1, move |request| {
15931 assert_eq!(
15932 request,
15933 format!(
15934 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
15935 "c".repeat(64)
15936 )
15937 );
15938 (200, path_manifest.clone())
15939 });
15940 let state = tempfile::tempdir().unwrap();
15941 let cfg = test_hub_config(hub, state.path().to_path_buf());
15942 let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
15943 .unwrap()
15944 .unwrap();
15945 assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
15946 assert!(by_path.proof.is_some());
15947 server.join().unwrap();
15948
15949 let (hub, server) = routed_json_hub(1, move |request| {
15950 assert_eq!(
15951 request,
15952 format!(
15953 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
15954 "c".repeat(64)
15955 )
15956 );
15957 (404, r#"{"error":"File not found"}"#.to_string())
15958 });
15959 let state = tempfile::tempdir().unwrap();
15960 let cfg = test_hub_config(hub, state.path().to_path_buf());
15961 assert!(
15962 v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
15963 .unwrap()
15964 .is_none()
15965 );
15966 server.join().unwrap();
15967
15968 let id_manifest = manifest;
15969 let (hub, server) = routed_json_hub(1, move |request| {
15970 assert_eq!(
15971 request,
15972 format!(
15973 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
15974 "c".repeat(64)
15975 )
15976 );
15977 (200, id_manifest.clone())
15978 });
15979 let state = tempfile::tempdir().unwrap();
15980 let cfg = test_hub_config(hub, state.path().to_path_buf());
15981 let (located_path, by_id) =
15982 v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
15983 assert_eq!(located_path, path);
15984 assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
15985 server.join().unwrap();
15986 }
15987
15988 #[test]
15989 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
15990 let unsorted = vec![
15991 ("records/a.md".to_string(), "alpha\n".to_string()),
15992 ("DB.md".to_string(), "# db\n".to_string()),
15993 ];
15994 let sorted = vec![
15995 ("DB.md".to_string(), "# db\n".to_string()),
15996 ("records/a.md".to_string(), "alpha\n".to_string()),
15997 ];
15998 let pack = build_store_pack(&unsorted).unwrap();
15999
16000 assert_eq!(pack.len(), 219);
16005 assert_eq!(
16006 content_sha256(&pack),
16007 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
16008 );
16009 assert_eq!(pack, build_store_pack(&sorted).unwrap());
16010 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
16011 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
16012 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
16013
16014 assert_eq!(
16015 parse_store_pack(pack).unwrap(),
16016 vec![
16017 ("DB.md".to_string(), b"# db\n".to_vec()),
16018 ("records/a.md".to_string(), b"alpha\n".to_vec()),
16019 ]
16020 );
16021 }
16022
16023 #[test]
16024 fn canonical_store_pack_validates_every_path_before_writing() {
16025 let duplicate = vec![
16026 ("DB.md".to_string(), "first".to_string()),
16027 ("DB.md".to_string(), "second".to_string()),
16028 ];
16029 assert!(build_store_pack(&duplicate)
16030 .unwrap_err()
16031 .to_string()
16032 .contains("duplicate path"));
16033 assert!(matches!(
16034 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
16035 Err(LinkError::UnsafePath { .. })
16036 ));
16037 }
16038
16039 #[test]
16040 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
16041 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16042 let mut bytes = vec![0_u8];
16045 let zip64_offset = bytes.len() as u64;
16046 bytes.extend_from_slice(b"PK\x06\x06");
16047 bytes.extend_from_slice(&44_u64.to_le_bytes());
16048 bytes.extend_from_slice(&[0_u8; 12]);
16049 bytes.extend_from_slice(&COUNT.to_le_bytes());
16050 bytes.extend_from_slice(&COUNT.to_le_bytes());
16051 bytes.extend_from_slice(&1_u64.to_le_bytes());
16052 bytes.extend_from_slice(&0_u64.to_le_bytes());
16053 bytes.extend_from_slice(b"PK\x06\x07");
16054 bytes.extend_from_slice(&0_u32.to_le_bytes());
16055 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16056 bytes.extend_from_slice(&1_u32.to_le_bytes());
16057 bytes.extend_from_slice(b"PK\x05\x06");
16058 bytes.extend_from_slice(&0_u16.to_le_bytes());
16059 bytes.extend_from_slice(&0_u16.to_le_bytes());
16060 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16061 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16062 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16063 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16064 bytes.extend_from_slice(&0_u16.to_le_bytes());
16065
16066 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16067 .unwrap_err()
16068 .to_string();
16069 assert!(error.contains("invalid file count"), "{error}");
16070 }
16071
16072 #[test]
16073 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
16074 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16075 let mut bytes = vec![0_u8];
16076 let zip64_offset = bytes.len() as u64;
16077 bytes.extend_from_slice(b"PK\x06\x06");
16078 bytes.extend_from_slice(&44_u64.to_le_bytes());
16079 bytes.extend_from_slice(&[0_u8; 12]);
16080 bytes.extend_from_slice(&COUNT.to_le_bytes());
16081 bytes.extend_from_slice(&COUNT.to_le_bytes());
16082 bytes.extend_from_slice(&1_u64.to_le_bytes());
16083 bytes.extend_from_slice(&0_u64.to_le_bytes());
16084 bytes.extend_from_slice(b"PK\x06\x07");
16085 bytes.extend_from_slice(&0_u32.to_le_bytes());
16086 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16087 bytes.extend_from_slice(&1_u32.to_le_bytes());
16088 bytes.extend_from_slice(b"PK\x05\x06");
16089 bytes.extend_from_slice(&0_u16.to_le_bytes());
16090 bytes.extend_from_slice(&0_u16.to_le_bytes());
16091 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16092 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16093 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16094 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16095 bytes.extend_from_slice(&0_u16.to_le_bytes());
16096 let fake_eocd = bytes.len() as u32;
16100 bytes.extend_from_slice(b"PK\x05\x06");
16101 bytes.extend_from_slice(&0_u16.to_le_bytes());
16102 bytes.extend_from_slice(&0_u16.to_le_bytes());
16103 bytes.extend_from_slice(&1_u16.to_le_bytes());
16104 bytes.extend_from_slice(&1_u16.to_le_bytes());
16105 bytes.extend_from_slice(&0_u32.to_le_bytes());
16106 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
16107 bytes.extend_from_slice(&0_u16.to_le_bytes());
16108
16109 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16110 .unwrap_err()
16111 .to_string();
16112 assert!(error.contains("central directory"), "{error}");
16113 }
16114
16115 #[test]
16116 fn strict_http_status_handling_rejects_redirects_without_panicking() {
16117 let error = ensure_ok(
16118 HubResponse {
16119 status: 302,
16120 body: Some(json!({"redirect": "/elsewhere"})),
16121 },
16122 "mutation",
16123 )
16124 .unwrap_err();
16125 assert!(matches!(error, LinkError::Http { status: 302, .. }));
16126
16127 let error = ensure_raw_ok(
16128 RawHubResponse {
16129 status: 302,
16130 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
16131 },
16132 "feed",
16133 )
16134 .unwrap_err();
16135 assert!(matches!(error, LinkError::Http { status: 302, .. }));
16136 }
16137
16138 #[cfg(unix)]
16139 #[test]
16140 fn collect_push_files_refuses_external_symlink_and_nested_store() {
16141 use std::os::unix::fs::symlink;
16142
16143 let root = tempfile::tempdir().unwrap();
16144 std::fs::write(
16145 root.path().join("DB.md"),
16146 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
16147 )
16148 .unwrap();
16149 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
16150
16151 let external = tempfile::tempdir().unwrap();
16152 let secret = external.path().join("secret.md");
16153 std::fs::write(&secret, "TOP SECRET").unwrap();
16154 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
16155
16156 let store = Store::open_strict(root.path()).unwrap();
16157 let err = collect_push_files(&store).unwrap_err().to_string();
16158 assert!(err.contains("cannot push"), "{err}");
16159 assert!(
16160 !err.contains("TOP SECRET"),
16161 "external bytes must never leak"
16162 );
16163
16164 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
16165 let nested = root.path().join("records/nested");
16166 std::fs::create_dir_all(&nested).unwrap();
16167 std::fs::write(
16168 nested.join("DB.md"),
16169 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
16170 )
16171 .unwrap();
16172 let err = collect_push_files(&store).unwrap_err().to_string();
16173 assert!(err.contains("nested db.md store"), "{err}");
16174 }
16175
16176 #[cfg(unix)]
16177 #[test]
16178 fn remote_push_uses_opened_root_after_path_replacement() {
16179 use std::os::unix::fs::symlink;
16180
16181 let sandbox = tempfile::tempdir().unwrap();
16182 let root = sandbox.path().join("store");
16183 std::fs::create_dir_all(root.join("records/notes")).unwrap();
16184 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16185 std::fs::write(
16186 root.join("records/notes/owned.md"),
16187 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
16188 )
16189 .unwrap();
16190 let store = Store::open_strict(&root).unwrap();
16191 let detached = sandbox.path().join("detached");
16192 std::fs::rename(&root, &detached).unwrap();
16193
16194 let replacement = sandbox.path().join("replacement");
16195 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
16196 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16197 std::fs::write(
16198 replacement.join("records/notes/secret.md"),
16199 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
16200 )
16201 .unwrap();
16202 symlink(&replacement, &root).unwrap();
16203
16204 let files = collect_push_files(&store).unwrap();
16205 let wire_text = files
16206 .iter()
16207 .map(|(path, content)| format!("{path}\n{content}"))
16208 .collect::<Vec<_>>()
16209 .join("\n");
16210 assert!(wire_text.contains("owned upload"));
16211 assert!(!wire_text.contains("replacement sentinel"));
16212 assert!(!wire_text.contains("records/notes/secret.md"));
16213
16214 let remote = signed_remote_fixture();
16215 let (hub, server) = scripted_json_hub(vec![
16216 (200, remote.card),
16217 (200, remote.feed),
16218 (200, json!({"ok": true}).to_string()),
16219 ]);
16220 let state = tempfile::tempdir().unwrap();
16221 let cfg = test_hub_config(hub, state.path().to_path_buf());
16222 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
16223 assert_eq!(pushed, json!({"ok": true}));
16224 server.join().unwrap();
16225 }
16226
16227 #[test]
16228 fn signed_feed_item_verifies_identity_hash_and_signature() {
16229 use ring::rand::SystemRandom;
16230 use ring::signature::{Ed25519KeyPair, KeyPair};
16231
16232 const PREFIX: &[u8] = &[
16233 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
16234 ];
16235 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
16236 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16237 let mut spki = PREFIX.to_vec();
16238 spki.extend_from_slice(pair.public_key().as_ref());
16239 let public_key = URL_SAFE_NO_PAD.encode(&spki);
16240 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
16241 let mut entry = FeedEntry {
16242 v: 1,
16243 seq: 1,
16244 ts: "2026-07-14T00:00:00.000Z".to_string(),
16245 brain: format!("ed25519:{fingerprint}"),
16246 public_key: public_key.clone(),
16247 kind: "push".to_string(),
16248 op: "snapshot".to_string(),
16249 pack_sha256: "a".repeat(64),
16250 files: vec![FeedFile {
16251 path: "DB.md".to_string(),
16252 sha256: "b".repeat(64),
16253 bytes: 3,
16254 }],
16255 removed: vec![],
16256 prev_entry_hash: None,
16257 sig: String::new(),
16258 };
16259 let unsigned = UnsignedFeedEntry {
16260 v: entry.v,
16261 seq: entry.seq,
16262 ts: &entry.ts,
16263 brain: &entry.brain,
16264 public_key: &entry.public_key,
16265 kind: &entry.kind,
16266 op: &entry.op,
16267 pack_sha256: &entry.pack_sha256,
16268 files: &entry.files,
16269 removed: &entry.removed,
16270 prev_entry_hash: &entry.prev_entry_hash,
16271 };
16272 entry.sig =
16273 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16274 let mut exact = serde_json::to_vec(&entry).unwrap();
16275 exact.push(b'\n');
16276 let item = FeedItem {
16277 hash: format!("{:x}", Sha256::digest(&exact)),
16278 entry,
16279 };
16280 let identity = FeedIdentity {
16281 fingerprint,
16282 public_key_spki: public_key,
16283 previous: Vec::new(),
16284 rotations: Vec::new(),
16285 };
16286 assert!(verify_feed_item(&item, &identity).is_ok());
16287 let mut tampered = item;
16288 tampered.entry.pack_sha256 = "c".repeat(64);
16289 assert!(verify_feed_item(&tampered, &identity).is_err());
16290 }
16291
16292 #[test]
16293 fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
16294 let rng = ring::rand::SystemRandom::new();
16295 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16296 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16297 let (spki, multikey) = public_identity_for(&pair);
16298 let identity = V2HeadIdentity {
16299 custody: "self".to_string(),
16300 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16301 public_key_spki: spki.clone(),
16302 previous: Vec::new(),
16303 rotations: Vec::new(),
16304 };
16305 let unsigned = json!({
16306 "actor_ref": "a".repeat(64),
16307 "asset_root": Value::Null,
16308 "brain": multikey,
16309 "changes_sha256": "b".repeat(64),
16310 "control_revision": "c".repeat(64),
16311 "materializer": "dbmd-projection-v1",
16312 "op": "changeset",
16313 "parent_asset_root": Value::Null,
16314 "parent_commit": Value::Null,
16315 "parent_root": Value::Null,
16316 "prev_entry_hash": Value::Null,
16317 "public_key": spki,
16318 "seq": 1,
16319 "signer_epoch": 1,
16320 "state_root": "d".repeat(64),
16321 "ts": "2026-08-19T12:00:00.000Z",
16322 "v": 2,
16323 "v1_bridge": {
16324 "feed_hash": "e".repeat(64),
16325 "head_seq": 7,
16326 "pack_sha256": "f".repeat(64),
16327 },
16328 });
16329 let sign_value = |value: Value| {
16330 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16331 let mut object = value.as_object().unwrap().clone();
16332 object.insert(
16333 "sig".to_string(),
16334 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16335 );
16336 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16337 };
16338 assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
16339
16340 let mut extra = unsigned.clone();
16341 extra
16342 .as_object_mut()
16343 .unwrap()
16344 .insert("future".to_string(), Value::Bool(true));
16345 assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
16346
16347 let mut missing = unsigned.clone();
16348 missing.as_object_mut().unwrap().remove("v1_bridge");
16349 assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
16350
16351 let mut invalid_bridge = unsigned;
16352 invalid_bridge.as_object_mut().unwrap().insert(
16353 "v1_bridge".to_string(),
16354 json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
16355 );
16356 assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
16357 }
16358
16359 #[test]
16360 fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
16361 let vector: Value = serde_json::from_str(include_str!(
16362 "../tests/vectors/linkmd-v2-commit-bridge.json"
16363 ))
16364 .unwrap();
16365 let identity_value = vector.get("identity").unwrap();
16366 let identity = V2HeadIdentity {
16367 custody: "self".to_string(),
16368 fingerprint: identity_value
16369 .get("fingerprint")
16370 .and_then(Value::as_str)
16371 .unwrap()
16372 .to_string(),
16373 public_key_spki: identity_value
16374 .get("public_key_spki")
16375 .and_then(Value::as_str)
16376 .unwrap()
16377 .to_string(),
16378 previous: Vec::new(),
16379 rotations: Vec::new(),
16380 };
16381 let private = URL_SAFE_NO_PAD
16382 .decode(
16383 identity_value
16384 .get("private_key_pkcs8")
16385 .and_then(Value::as_str)
16386 .unwrap(),
16387 )
16388 .unwrap();
16389 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
16390 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
16391 .unwrap();
16392 let base = vector.get("body").unwrap().as_object().unwrap();
16393
16394 for item in vector.get("valid").unwrap().as_array().unwrap() {
16395 let mut body = base.clone();
16396 body.insert(
16397 "v1_bridge".to_string(),
16398 item.get("v1_bridge").unwrap().clone(),
16399 );
16400 body.insert(
16401 "sig".to_string(),
16402 item.get("signature_base64url").unwrap().clone(),
16403 );
16404 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16405 assert!(verified_v2_commit_object(&signed, &identity).is_ok());
16406 assert_eq!(
16407 crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
16408 item.get("commit_hash").and_then(Value::as_str).unwrap()
16409 );
16410 assert_eq!(
16411 format!("{:x}", Sha256::digest(&signed)),
16412 item.get("feed_hash").and_then(Value::as_str).unwrap()
16413 );
16414 }
16415
16416 for item in vector.get("invalid").unwrap().as_array().unwrap() {
16417 let mut body = base.clone();
16418 if let Some(remove) = item.get("remove").and_then(Value::as_array) {
16419 for field in remove {
16420 body.remove(field.as_str().unwrap());
16421 }
16422 }
16423 if let Some(set) = item.get("set").and_then(Value::as_object) {
16424 for (field, value) in set {
16425 body.insert(field.clone(), value.clone());
16426 }
16427 }
16428 let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
16429 body.insert(
16430 "sig".to_string(),
16431 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16432 );
16433 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16434 assert!(
16435 verified_v2_commit_object(&signed, &identity).is_err(),
16436 "accepted invalid shared vector {}",
16437 item.get("reason").and_then(Value::as_str).unwrap()
16438 );
16439 }
16440 }
16441
16442 #[test]
16443 fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
16444 let vector: Value = serde_json::from_str(include_str!(
16445 "../tests/vectors/linkmd-v2-changeset-withheld.json"
16446 ))
16447 .unwrap();
16448 assert_eq!(
16449 vector.get("profile").and_then(Value::as_str),
16450 Some("link.md-v2-changeset-withheld")
16451 );
16452 let canonical =
16453 crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
16454 let expected = STANDARD
16455 .decode(
16456 vector
16457 .get("canonical_base64")
16458 .and_then(Value::as_str)
16459 .unwrap(),
16460 )
16461 .unwrap();
16462 assert_eq!(canonical, expected);
16463 assert_eq!(
16464 crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
16465 vector.get("domain_hash").and_then(Value::as_str).unwrap()
16466 );
16467 }
16468
16469 #[test]
16470 fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
16471 let remote = signed_remote_fixture();
16472 let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
16473 let legacy_item = legacy.entries.first().unwrap();
16474 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
16475 let body = json!({
16476 "actor_ref": "a".repeat(64),
16477 "asset_root": Value::Null,
16478 "brain": remote.key.multikey,
16479 "changes_sha256": "b".repeat(64),
16480 "control_revision": "c".repeat(64),
16481 "materializer": "dbmd-projection-v1",
16482 "op": "changeset",
16483 "parent_asset_root": Value::Null,
16484 "parent_commit": Value::Null,
16485 "parent_root": Value::Null,
16486 "prev_entry_hash": Value::Null,
16487 "public_key": remote.key.public_key_spki,
16488 "seq": 1,
16489 "signer_epoch": 1,
16490 "state_root": "d".repeat(64),
16491 "ts": "2026-08-19T12:00:00.000Z",
16492 "v": 2,
16493 "v1_bridge": {
16494 "feed_hash": legacy_item.hash,
16495 "head_seq": legacy_item.entry.seq,
16496 "pack_sha256": legacy_item.entry.pack_sha256,
16497 },
16498 });
16499 let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
16500 let mut signed = body.as_object().unwrap().clone();
16501 signed.insert(
16502 "sig".to_string(),
16503 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16504 );
16505 let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
16506 let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
16507 let feed_hash = content_sha256(&raw);
16508 let pointer = V2PointerBody {
16509 v: 2,
16510 brain: TEST_BRAIN_ID.to_string(),
16511 seq: 1,
16512 commit_hash: commit_hash.clone(),
16513 feed_hash: feed_hash.clone(),
16514 content_root: Some("d".repeat(64)),
16515 asset_root: None,
16516 materializer: "dbmd-projection-v1".to_string(),
16517 signer_epoch: 1,
16518 control_revision: "c".repeat(64),
16519 backup_preparation: "e".repeat(64),
16520 prior_pointer_hash: None,
16521 signed_at: "2026-08-19T12:00:00.000Z".to_string(),
16522 };
16523 let v2_page = json!({
16524 "v": 2,
16525 "head_seq": 1,
16526 "head_commit_hash": commit_hash,
16527 "head_feed_hash": feed_hash,
16528 "entries": [{
16529 "seq": 1,
16530 "commit_hash": pointer.commit_hash,
16531 "feed_hash": pointer.feed_hash,
16532 "bytes_base64": STANDARD.encode(&raw),
16533 }],
16534 "next_after": 1,
16535 "complete": true,
16536 })
16537 .to_string();
16538 let identity = V2HeadIdentity {
16539 custody: "self".to_string(),
16540 fingerprint: remote.identity.fingerprint.clone(),
16541 public_key_spki: remote.identity.public_key_spki.clone(),
16542 previous: Vec::new(),
16543 rotations: Vec::new(),
16544 };
16545 let checkpoint = TrustState {
16546 v: 2,
16547 origin: "unused".to_string(),
16548 requested: TEST_BRAIN_ID.to_string(),
16549 brain: TEST_BRAIN_ID.to_string(),
16550 home: None,
16551 anchor: remote.key.multikey.clone(),
16552 current: remote.key.multikey,
16553 head_seq: legacy_item.entry.seq,
16554 feed_hash: Some(legacy_item.hash.clone()),
16555 rotations: Vec::new(),
16556 hub_signer: None,
16557 protocol_profile: None,
16558 };
16559 let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
16560 let state = tempfile::tempdir().unwrap();
16561 let cfg = test_hub_config(hub, state.path().to_path_buf());
16562 verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
16563 server.join().unwrap();
16564
16565 let mut wrong = checkpoint;
16566 wrong.feed_hash = Some("0".repeat(64));
16567 let (hub, server) = scripted_json_hub(vec![(
16568 200,
16569 json!({
16570 "v": 2,
16571 "head_seq": 1,
16572 "head_commit_hash": pointer.commit_hash,
16573 "head_feed_hash": pointer.feed_hash,
16574 "entries": [{
16575 "seq": 1,
16576 "commit_hash": pointer.commit_hash,
16577 "feed_hash": pointer.feed_hash,
16578 "bytes_base64": STANDARD.encode(&raw),
16579 }],
16580 "next_after": 1,
16581 "complete": true,
16582 })
16583 .to_string(),
16584 )]);
16585 let state = tempfile::tempdir().unwrap();
16586 let cfg = test_hub_config(hub, state.path().to_path_buf());
16587 assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
16588 server.join().unwrap();
16589 }
16590
16591 #[test]
16592 fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
16593 let rng = ring::rand::SystemRandom::new();
16594 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16595 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
16596 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16597 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
16598 let (old_spki, old_multikey) = public_identity_for(&old);
16599 let (new_spki, new_multikey) = public_identity_for(&new);
16600 let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
16601 v: 1,
16602 op: "rotate",
16603 brain: &old_multikey,
16604 public_key: &old_spki,
16605 new_brain: &new_multikey,
16606 new_public_key: &new_spki,
16607 prior_head_seq: 1,
16608 prior_feed_hash: Some(&"9".repeat(64)),
16609 ts: "2026-08-19T12:01:00.000Z".to_string(),
16610 })
16611 .unwrap();
16612 let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
16613 let rotation = format!(
16614 "{},\"sig\":\"{}\"}}",
16615 &rotation_unsigned[..rotation_unsigned.len() - 1],
16616 rotation_sig
16617 );
16618 let identity = V2HeadIdentity {
16619 custody: "self".to_string(),
16620 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
16621 public_key_spki: new_spki.clone(),
16622 previous: vec![V2PreviousIdentity {
16623 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
16624 public_key_spki: old_spki.clone(),
16625 }],
16626 rotations: vec![rotation],
16627 };
16628 let commit = |seq: u64,
16629 epoch: u64,
16630 multikey: &str,
16631 spki: &str,
16632 pair: &ring::signature::Ed25519KeyPair| {
16633 let value = json!({
16634 "actor_ref": "a".repeat(64),
16635 "asset_root": Value::Null,
16636 "brain": multikey,
16637 "changes_sha256": "b".repeat(64),
16638 "control_revision": "c".repeat(64),
16639 "materializer": "dbmd-projection-v1",
16640 "op": "changeset",
16641 "parent_asset_root": Value::Null,
16642 "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
16643 "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
16644 "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
16645 "public_key": spki,
16646 "seq": seq,
16647 "signer_epoch": epoch,
16648 "state_root": "1".repeat(64),
16649 "ts": "2026-08-19T12:00:00.000Z",
16650 "v": 2,
16651 "v1_bridge": Value::Null,
16652 });
16653 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16654 let mut object = value.as_object().unwrap().clone();
16655 object.insert(
16656 "sig".to_string(),
16657 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16658 );
16659 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16660 };
16661
16662 assert!(verified_v2_commit_object(
16663 &commit(1, 1, &old_multikey, &old_spki, &old),
16664 &identity,
16665 )
16666 .is_ok());
16667 assert!(verified_v2_commit_object(
16668 &commit(2, 2, &new_multikey, &new_spki, &new),
16669 &identity,
16670 )
16671 .is_ok());
16672 assert!(verified_v2_commit_object(
16673 &commit(2, 1, &old_multikey, &old_spki, &old),
16674 &identity,
16675 )
16676 .is_err());
16677 assert!(verified_v2_commit_object(
16678 &commit(1, 2, &new_multikey, &new_spki, &new),
16679 &identity,
16680 )
16681 .is_err());
16682 }
16683
16684 #[test]
16685 fn a_self_custody_entry_verifies_like_any_hub_entry() {
16686 let rng = ring::rand::SystemRandom::new();
16687 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16688 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16689 let (spki, multikey) = public_identity_for(&pair);
16690 let key = AgentSigningKey {
16691 pkcs8: pkcs8.as_ref().to_vec(),
16692 multikey: multikey.clone(),
16693 public_key_spki: spki.clone(),
16694 };
16695 let files = vec![WireFeedFile {
16696 path: "DB.md".to_string(),
16697 sha256: "a".repeat(64),
16698 bytes: 3,
16699 }];
16700 let raw = self_custody_entry(
16701 &key,
16702 1,
16703 "2026-07-23T12:00:00.000Z".to_string(),
16704 &"c".repeat(64),
16705 &files,
16706 None,
16707 )
16708 .unwrap();
16709 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
16713 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
16714 let item = FeedItem { hash, entry };
16715 let identity = FeedIdentity {
16716 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16717 public_key_spki: spki,
16718 previous: Vec::new(),
16719 rotations: Vec::new(),
16720 };
16721 assert!(verify_feed_item(&item, &identity).is_ok());
16722 }
16723
16724 #[test]
16725 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
16726 let rng = ring::rand::SystemRandom::new();
16727 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16728 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
16729 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16730 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
16731 let (old_spki, old_multikey) = public_identity_for(&old);
16732 let (new_spki, new_multikey) = public_identity_for(&new);
16733 let unsigned = serde_json::to_string(&UnsignedRotation {
16734 v: 1,
16735 op: "rotate",
16736 brain: &old_multikey,
16737 public_key: &old_spki,
16738 new_brain: &new_multikey,
16739 new_public_key: &new_spki,
16740 prior_head_seq: 1,
16741 prior_feed_hash: Some(&"a".repeat(64)),
16742 ts: "2026-07-30T12:00:00.000Z".to_string(),
16743 })
16744 .unwrap();
16745 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
16746 let rotation = format!(
16747 "{},\"sig\":\"{}\"}}",
16748 &unsigned[..unsigned.len() - 1],
16749 signature
16750 );
16751 let identity = FeedIdentity {
16752 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
16753 public_key_spki: new_spki,
16754 previous: vec![PreviousIdentity {
16755 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
16756 public_key_spki: old_spki,
16757 }],
16758 rotations: vec![rotation],
16759 };
16760 let pin = TrustState {
16761 v: 2,
16762 origin: "https://hub.example".to_string(),
16763 requested: "brain".to_string(),
16764 brain: "brain".to_string(),
16765 home: None,
16766 anchor: old_multikey.clone(),
16767 current: old_multikey.clone(),
16768 head_seq: 1,
16769 feed_hash: Some("a".repeat(64)),
16770 rotations: Vec::new(),
16771 hub_signer: None,
16772 protocol_profile: None,
16773 };
16774 assert_eq!(
16775 verify_identity_chain(&identity, Some(&pin)).unwrap(),
16776 old_multikey
16777 );
16778 let mut accepted = pin.clone();
16779 accepted.current = new_multikey.clone();
16780 accepted.rotations = identity.rotations.clone();
16781 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
16782 v: 1,
16783 op: "rotate",
16784 brain: &old_multikey,
16785 public_key: &identity.previous[0].public_key_spki,
16786 new_brain: &new_multikey,
16787 new_public_key: &identity.public_key_spki,
16788 prior_head_seq: 1,
16789 prior_feed_hash: Some(&"a".repeat(64)),
16790 ts: "2026-07-30T12:00:01.000Z".to_string(),
16791 })
16792 .unwrap();
16793 let alternate_signature =
16794 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
16795 let mut rewritten = identity.clone();
16796 rewritten.rotations[0] = format!(
16797 "{},\"sig\":\"{}\"}}",
16798 &alternate_unsigned[..alternate_unsigned.len() - 1],
16799 alternate_signature
16800 );
16801 assert!(
16802 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
16803 "an alternate valid statement must not rewrite accepted history"
16804 );
16805
16806 let mut stale_entry = FeedEntry {
16807 v: 1,
16808 seq: 2,
16809 ts: "2026-07-30T12:01:00.000Z".to_string(),
16810 brain: pin.current.clone(),
16811 public_key: identity.previous[0].public_key_spki.clone(),
16812 kind: "push".to_string(),
16813 op: "snapshot".to_string(),
16814 pack_sha256: "b".repeat(64),
16815 files: Vec::new(),
16816 removed: Vec::new(),
16817 prev_entry_hash: pin.feed_hash.clone(),
16818 sig: String::new(),
16819 };
16820 let stale_unsigned = UnsignedFeedEntry {
16821 v: stale_entry.v,
16822 seq: stale_entry.seq,
16823 ts: &stale_entry.ts,
16824 brain: &stale_entry.brain,
16825 public_key: &stale_entry.public_key,
16826 kind: &stale_entry.kind,
16827 op: &stale_entry.op,
16828 pack_sha256: &stale_entry.pack_sha256,
16829 files: &stale_entry.files,
16830 removed: &stale_entry.removed,
16831 prev_entry_hash: &stale_entry.prev_entry_hash,
16832 };
16833 stale_entry.sig = URL_SAFE_NO_PAD.encode(
16834 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
16835 .as_ref(),
16836 );
16837 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
16838 stale_exact.push(b'\n');
16839 let stale_item = FeedItem {
16840 hash: content_sha256(&stale_exact),
16841 entry: stale_entry,
16842 };
16843 assert!(
16844 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
16845 .is_err(),
16846 "a key retired before the checkpoint must never append after it"
16847 );
16848 assert!(
16849 verify_feed_item(&stale_item, &identity).is_err(),
16850 "an old key must never append after its signed rotation boundary"
16851 );
16852
16853 let mut missing = identity.clone();
16854 missing.rotations.clear();
16855 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
16856
16857 let mut tampered = identity;
16858 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
16859 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
16860 }
16861
16862 #[cfg(unix)]
16863 #[test]
16864 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
16865 use std::os::unix::fs::symlink;
16866
16867 let dir = tempfile::tempdir().unwrap();
16868 let target = dir.path().join("valuable.txt");
16869 let planted = dir.path().join("agent.key");
16870 std::fs::write(&target, "do not overwrite").unwrap();
16871 symlink(&target, &planted).unwrap();
16872
16873 assert!(matches!(
16874 generate_agent_key(&planted),
16875 Err(LinkError::BadAgentKey { .. })
16876 ));
16877 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
16878 }
16879
16880 #[cfg(unix)]
16881 #[test]
16882 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
16883 use std::os::unix::fs::symlink;
16884
16885 let root = tempfile::tempdir().unwrap();
16886 let outside = tempfile::tempdir().unwrap();
16887 symlink(outside.path(), root.path().join("redirect")).unwrap();
16888
16889 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
16890 assert!(!outside.path().join("agent.key").exists());
16891 }
16892
16893 #[test]
16896 fn address_bare_brain_with_and_without_sigil() {
16897 for raw in ["@acme-ops", "acme-ops"] {
16898 let a = Address::parse(raw).expect(raw);
16899 assert_eq!(a.brain, "acme-ops");
16900 assert_eq!(a.target, None);
16901 }
16902 }
16903
16904 #[test]
16905 fn address_ulid_target_parses_as_id() {
16906 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
16907 assert_eq!(a.brain, "acme");
16908 assert_eq!(
16909 a.target,
16910 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
16911 );
16912 }
16913
16914 #[test]
16915 fn address_md_path_target_parses_as_path() {
16916 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
16917 assert_eq!(
16918 a.target,
16919 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
16920 );
16921 }
16922
16923 #[test]
16924 fn address_rejects_malformed_forms() {
16925 for raw in [
16926 "",
16927 "@",
16928 "@/x",
16929 "@acme/",
16930 "@acme/../etc/passwd",
16931 "@acme/records/.hidden.md",
16932 "@ACME", "@acme/notes/x.txt", "@a b", ] {
16936 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
16937 }
16938 }
16939
16940 #[test]
16943 fn safe_paths_accept_store_shapes_and_reject_escapes() {
16944 for ok in [
16945 "DB.md",
16946 "assets.jsonl",
16947 "records/clients/lumio.md",
16948 "sources/emails/2026/07/x.md",
16949 ] {
16950 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
16951 }
16952 for bad in [
16953 "",
16954 "/etc/passwd",
16955 "../up.md",
16956 "records/../../up.md",
16957 "records//x.md",
16958 ".dbmd/config",
16959 "records/.hidden/x.md",
16960 "records/a b.md",
16961 "records\\win.md",
16962 ] {
16963 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
16964 }
16965 }
16966
16967 #[cfg(unix)]
16968 #[test]
16969 fn opened_destination_capability_survives_an_ancestor_path_swap() {
16970 use std::os::unix::fs::symlink;
16971
16972 let work = tempfile::tempdir().unwrap();
16973 let outside = tempfile::tempdir().unwrap();
16974 let original = work.path().join("destination");
16975 let moved = work.path().join("destination-moved");
16976 let directory = open_or_create_dir_nofollow(&original).unwrap();
16977
16978 std::fs::rename(&original, &moved).unwrap();
16979 symlink(outside.path(), &original).unwrap();
16980 write_pull_entries_beneath_dir(
16981 &directory,
16982 &[("records/note.md".to_string(), b"held inode".to_vec())],
16983 )
16984 .unwrap();
16985
16986 assert_eq!(
16987 std::fs::read(moved.join("records/note.md")).unwrap(),
16988 b"held inode"
16989 );
16990 assert!(!outside.path().join("records/note.md").exists());
16991 }
16992
16993 #[test]
16997 fn hub_config_flag_beats_file_and_requires_some_source() {
16998 let dir = tempfile::tempdir().unwrap();
16999 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
17000 std::fs::write(
17001 dir.path().join(CONFIG_REL_PATH),
17002 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
17003 )
17004 .unwrap();
17005
17006 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
17007 assert_eq!(from_flag.hub, "https://flag.example.com");
17008
17009 let from_file = hub_config(None, dir.path()).unwrap();
17010 assert_eq!(from_file.hub, "https://file.example.com");
17011
17012 let none = hub_config(None, tempfile::tempdir().unwrap().path());
17013 assert!(matches!(none, Err(LinkError::NoHub)));
17014 }
17015
17016 #[test]
17017 fn https_guard_allows_loopback_only_for_plain_http() {
17018 assert!(assert_safe_hub("https://hub.example.com").is_ok());
17019 assert!(assert_safe_hub("http://localhost:3000").is_ok());
17020 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
17021 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
17022 assert!(matches!(
17023 assert_safe_hub("http://hub.example.com"),
17024 Err(LinkError::UnsafeHub { .. })
17025 ));
17026 assert!(matches!(
17027 assert_safe_hub("hub.example.com"),
17028 Err(LinkError::UnsafeHub { .. })
17029 ));
17030 assert!(matches!(
17031 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
17032 Err(LinkError::UnsafeHub { .. })
17033 ));
17034 assert!(matches!(
17035 assert_safe_hub("https://hub.example.com@attacker.example"),
17036 Err(LinkError::UnsafeHub { .. })
17037 ));
17038 assert!(matches!(
17039 assert_safe_hub("https://hub.example.com/base"),
17040 Err(LinkError::UnsafeHub { .. })
17041 ));
17042 }
17043
17044 #[test]
17045 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
17046 for blocked in [
17047 "127.0.0.1",
17048 "10.0.0.1",
17049 "100.64.0.1",
17050 "169.254.169.254",
17051 "172.16.0.1",
17052 "192.168.0.1",
17053 "192.88.99.1",
17054 "198.18.0.1",
17055 "203.0.113.1",
17056 "::1",
17057 "fe80::1",
17058 "fd00::1",
17059 "2001:db8::1",
17060 "2001:1::1",
17061 "2002:7f00:1::",
17062 "3fff::1",
17063 ] {
17064 assert!(
17065 !is_public_registry_ip(blocked.parse().unwrap()),
17066 "must block {blocked}"
17067 );
17068 }
17069 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
17070 assert!(is_public_registry_ip(
17071 "2606:4700:4700::1111".parse().unwrap()
17072 ));
17073 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
17074 }
17075
17076 #[test]
17077 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
17078 use ureq::Resolver as _;
17079
17080 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
17081 let resolver = PinnedRegistryResolver {
17082 netloc: "home.example:443".to_string(),
17083 addresses: vec![pinned],
17084 };
17085 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
17086 assert!(resolver.resolve("127.0.0.1:443").is_err());
17087 assert_eq!(
17088 resolver.resolve("home.example:443").unwrap(),
17089 vec![pinned],
17090 "subsequent connects reuse the validated answer instead of DNS"
17091 );
17092 }
17093
17094 #[test]
17095 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
17096 let cfg = HubConfig {
17097 hub: "https://hub.example".to_string(),
17098 key: None,
17099 agent_key: None,
17100 brain_key: None,
17101 state_dir: tempfile::tempdir().unwrap().keep(),
17102 store_selected: false,
17103 };
17104 assert!(
17105 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
17106 "a production hub must not turn its presigned URL into an SSRF primitive"
17107 );
17108
17109 let store_selected = HubConfig {
17110 hub: "https://127.0.0.1".to_string(),
17111 store_selected: true,
17112 ..cfg
17113 };
17114 assert!(
17115 hub_agent(&store_selected).is_err(),
17116 "bytes in a cloned store must not select a private-network hub"
17117 );
17118 }
17119
17120 #[test]
17121 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
17122 assert_eq!(
17123 one_past_bounded_limit(MAX_PACK_BYTES),
17124 Some(MAX_PACK_BYTES + 1),
17125 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
17126 );
17127 assert_eq!(
17128 presigned_download_read_limit(),
17129 MAX_PACK_BYTES + 1,
17130 "the presigned reader is capped by the client constant, not a hub response"
17131 );
17132 assert_eq!(
17133 one_past_bounded_limit(u64::MAX),
17134 None,
17135 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
17136 );
17137 }
17138
17139 #[test]
17140 fn https_guard_matches_the_scheme_case_insensitively() {
17141 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
17144 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
17145 assert!(matches!(
17147 assert_safe_hub("HTTP://hub.example.com"),
17148 Err(LinkError::UnsafeHub { .. })
17149 ));
17150 }
17151
17152 #[test]
17153 fn clean_key_refuses_paste_artifacts_without_echoing() {
17154 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
17155 for bad in ["vc account", "vc\naccount", "ключ", ""] {
17156 let err = clean_key(bad).unwrap_err();
17157 assert!(matches!(err, LinkError::BadKey));
17158 assert!(
17159 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
17160 "error must not echo the key"
17161 );
17162 }
17163 }
17164
17165 fn dead_hub() -> HubConfig {
17171 HubConfig {
17172 hub: "http://127.0.0.1:9".to_string(),
17173 key: Some("k".to_string()),
17174 agent_key: None,
17175 brain_key: None,
17176 state_dir: PathBuf::from("."),
17177 store_selected: false,
17178 }
17179 }
17180
17181 #[test]
17182 fn request_retries_a_connection_failure_before_sending() {
17183 use std::io::{Read as _, Write as _};
17184 use std::net::TcpListener;
17185 use std::thread;
17186 use std::time::Duration;
17187
17188 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
17189 let address = probe.local_addr().unwrap();
17190 drop(probe);
17191 let server = thread::spawn(move || {
17192 thread::sleep(Duration::from_millis(40));
17193 let listener = TcpListener::bind(address).unwrap();
17194 let (mut stream, _) = listener.accept().unwrap();
17195 let mut request_bytes = [0_u8; 1024];
17196 let _ = stream.read(&mut request_bytes).unwrap();
17197 stream
17198 .write_all(
17199 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17200 )
17201 .unwrap();
17202 });
17203 let cfg = HubConfig {
17204 hub: format!("http://{address}"),
17205 key: None,
17206 agent_key: None,
17207 brain_key: None,
17208 state_dir: tempfile::tempdir().unwrap().keep(),
17209 store_selected: false,
17210 };
17211
17212 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
17213 assert_eq!(response.status, 200);
17214 assert_eq!(response.body, Some(json!({ "ok": true })));
17215 server.join().unwrap();
17216 }
17217
17218 #[test]
17219 fn a_commit_goes_back_for_a_receipt_it_lost() {
17220 use std::io::{Read as _, Write as _};
17221 use std::net::TcpListener;
17222 use std::thread;
17223
17224 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17230 let address = listener.local_addr().unwrap();
17231 let server = thread::spawn(move || {
17232 let (mut first, _) = listener.accept().unwrap();
17234 let mut bytes = [0_u8; 4096];
17235 let _ = first.read(&mut bytes).unwrap();
17236 first
17237 .write_all(
17238 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
17239 )
17240 .unwrap();
17241 drop(first);
17242 let (mut second, _) = listener.accept().unwrap();
17244 let _ = second.read(&mut bytes).unwrap();
17245 second
17246 .write_all(
17247 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\"}",
17248 )
17249 .unwrap();
17250 });
17251 let cfg = HubConfig {
17252 hub: format!("http://{address}"),
17253 key: Some("k".to_string()),
17254 agent_key: None,
17255 brain_key: None,
17256 state_dir: tempfile::tempdir().unwrap().keep(),
17257 store_selected: false,
17258 };
17259
17260 let response = request_patient(
17261 &cfg,
17262 "POST",
17263 "/api/hub/brains/b/v2/commits",
17264 Some(&json!({ "mutation_id": "dbmd-1" })),
17265 Auth::Required,
17266 )
17267 .expect("the receipt is collected on the second ask");
17268 assert_eq!(response.status, 200);
17269 assert_eq!(
17270 response
17271 .body
17272 .as_ref()
17273 .and_then(|value| value.get("outcome"))
17274 .and_then(Value::as_str),
17275 Some("converged"),
17276 "an already-applied mutation answers with its receipt"
17277 );
17278 server.join().unwrap();
17279 }
17280
17281 #[test]
17282 fn a_body_that_dies_mid_stream_is_a_transport_failure() {
17283 use std::io::{Read as _, Write as _};
17284 use std::net::TcpListener;
17285 use std::thread;
17286
17287 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17293 let address = listener.local_addr().unwrap();
17294 let server = thread::spawn(move || {
17295 let (mut stream, _) = listener.accept().unwrap();
17296 let mut request_bytes = [0_u8; 1024];
17297 let _ = stream.read(&mut request_bytes).unwrap();
17298 stream
17300 .write_all(
17301 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17302 )
17303 .unwrap();
17304 });
17305 let cfg = HubConfig {
17306 hub: format!("http://{address}"),
17307 key: None,
17308 agent_key: None,
17309 brain_key: None,
17310 state_dir: tempfile::tempdir().unwrap().keep(),
17311 store_selected: false,
17312 };
17313
17314 let error = request(&cfg, "GET", "/truncated", None, Auth::None)
17315 .expect_err("a truncated body must not read as success");
17316 match error {
17317 LinkError::Transport { hub, .. } => {
17318 assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
17319 }
17320 other => panic!("expected a transport failure, got {other:?}"),
17321 }
17322 server.join().unwrap();
17323 }
17324
17325 #[test]
17326 fn object_store_transport_errors_never_render_presigned_urls() {
17327 use std::net::TcpListener;
17328
17329 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17330 let address = listener.local_addr().unwrap();
17331 drop(listener);
17332 let signature = "do-not-render-this-presigned-signature";
17333 let raw =
17334 format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
17335 let error = ureq::get(&raw)
17336 .timeout(std::time::Duration::from_millis(250))
17337 .call()
17338 .expect_err("the closed local port must fail");
17339 let ureq::Error::Transport(transport) = error else {
17340 panic!("expected a transport failure");
17341 };
17342
17343 let rendered = object_store_transport_error(transport).to_string();
17344 assert!(rendered.contains("the object store"));
17345 assert!(rendered.contains("network error"));
17346 assert!(!rendered.contains(&raw));
17347 assert!(!rendered.contains(signature));
17348 assert!(!rendered.contains("X-Amz-"));
17349 }
17350
17351 #[test]
17352 fn endpoint_cap_refuses_a_body_before_json_parsing() {
17353 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
17354 let cfg = HubConfig {
17355 hub,
17356 key: None,
17357 agent_key: None,
17358 brain_key: None,
17359 state_dir: tempfile::tempdir().unwrap().keep(),
17360 store_selected: false,
17361 };
17362
17363 assert!(matches!(
17364 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
17365 Err(LinkError::ResponseTooLarge { .. })
17366 ));
17367 server.join().unwrap();
17368 }
17369
17370 #[test]
17371 fn overall_deadline_stops_a_dribbled_response_body() {
17372 use std::io::{Read as _, Write as _};
17373 use std::net::TcpListener;
17374 use std::time::{Duration, Instant};
17375
17376 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17377 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
17378 let server = std::thread::spawn(move || {
17379 let (mut stream, _) = listener.accept().unwrap();
17380 let mut request = [0_u8; 1024];
17381 let _ = stream.read(&mut request);
17382 stream
17383 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
17384 .unwrap();
17385 for byte in [b'x'; 32] {
17386 if stream.write_all(&[byte]).is_err() {
17387 break;
17388 }
17389 std::thread::sleep(Duration::from_millis(40));
17390 }
17391 });
17392 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17393 let started = Instant::now();
17394 let response = http.get(&url).call().unwrap();
17395 let mut body = Vec::new();
17396 let error = response
17397 .into_reader()
17398 .read_to_end(&mut body)
17399 .expect_err("per-read progress must not reset the overall deadline");
17400 assert!(
17401 started.elapsed() < Duration::from_millis(700),
17402 "dribbled body exceeded the wall-clock budget: {error}"
17403 );
17404 server.join().unwrap();
17405 }
17406
17407 #[test]
17408 fn overall_deadline_stops_a_stalled_upload() {
17409 use std::net::TcpListener;
17410 use std::time::{Duration, Instant};
17411
17412 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17413 let url = format!("http://{}/upload", listener.local_addr().unwrap());
17414 let server = std::thread::spawn(move || {
17415 let (_stream, _) = listener.accept().unwrap();
17416 std::thread::sleep(Duration::from_millis(600));
17419 });
17420 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17421 let body = vec![0x5a; 32 * 1024 * 1024];
17422 let started = Instant::now();
17423 let error = http
17424 .put(&url)
17425 .send_bytes(&body)
17426 .expect_err("stalled request-body writes must time out");
17427 assert!(
17428 started.elapsed() < Duration::from_millis(700),
17429 "stalled upload exceeded the wall-clock budget: {error}"
17430 );
17431 server.join().unwrap();
17432 }
17433
17434 #[test]
17435 fn presigned_source_retries_share_one_upload_deadline() {
17436 use std::net::TcpListener;
17437 use std::time::{Duration, Instant};
17438
17439 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17440 let address = listener.local_addr().unwrap();
17441 let signature = "do-not-render-this-stalled-upload-signature";
17442 let url = format!(
17443 "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
17444 );
17445 let server = std::thread::spawn(move || {
17446 let (_stream, _) = listener.accept().unwrap();
17447 std::thread::sleep(Duration::from_millis(600));
17451 });
17452
17453 let directory = tempfile::tempdir().unwrap();
17454 std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17455 std::fs::create_dir(directory.path().join("records")).unwrap();
17456 let relative = "records/stalled.bin";
17457 let bytes = vec![0x5a; 32 * 1024 * 1024];
17458 std::fs::write(directory.path().join(relative), &bytes).unwrap();
17459 let store = Store::open_strict(directory.path()).unwrap();
17460 let cfg = HubConfig {
17461 hub: format!("http://{address}"),
17462 key: None,
17463 agent_key: None,
17464 brain_key: None,
17465 state_dir: tempfile::tempdir().unwrap().keep(),
17466 store_selected: false,
17467 };
17468 let source = V2UploadSource {
17469 path: relative.to_string(),
17470 bytes: bytes.len() as u64,
17471 };
17472
17473 let started = Instant::now();
17474 let error = put_presigned_source_with_budget(
17475 &cfg,
17476 &url,
17477 &json!({ "content-length": source.bytes.to_string() }),
17478 &store,
17479 &source,
17480 None,
17481 Duration::from_millis(150),
17482 )
17483 .expect_err("a black-holed upload must leave at its shared deadline");
17484 assert!(
17485 started.elapsed() < Duration::from_millis(700),
17486 "presigned retries exceeded their shared budget: {error}"
17487 );
17488 let rendered = error.to_string();
17489 assert!(rendered.contains("the object store"));
17490 assert!(!rendered.contains(&url));
17491 assert!(!rendered.contains(signature));
17492 server.join().unwrap();
17493 }
17494
17495 #[test]
17496 fn verb_entry_gates_accept_the_hub_ref_shapes() {
17497 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
17498 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
17499 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
17500 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
17501 }
17502 }
17503
17504 #[test]
17505 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
17506 let cfg = dead_hub();
17507 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
17508 assert!(
17509 matches!(
17510 sync_pull(&cfg, bad, None),
17511 Err(LinkError::BadAddress { .. })
17512 ),
17513 "sync_pull must refuse {bad:?}"
17514 );
17515 assert!(
17516 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
17517 "sync_push must refuse {bad:?}"
17518 );
17519 assert!(
17520 matches!(
17521 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
17522 Err(LinkError::BadAddress { .. })
17523 ),
17524 "grant_issue must refuse {bad:?}"
17525 );
17526 assert!(
17527 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
17528 "grant_list must refuse {bad:?}"
17529 );
17530 assert!(
17531 matches!(
17532 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
17533 Err(LinkError::BadAddress { .. })
17534 ),
17535 "grant_revoke must refuse brain {bad:?}"
17536 );
17537 assert!(
17538 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
17539 "head must refuse {bad:?}"
17540 );
17541 }
17542 }
17543
17544 #[test]
17545 fn grant_revoke_refuses_url_reshaping_grant_ids() {
17546 let cfg = dead_hub();
17547 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
17548 assert!(
17549 matches!(
17550 grant_revoke(&cfg, "acme", bad),
17551 Err(LinkError::BadGrantId { .. })
17552 ),
17553 "grant_revoke must refuse grant id {bad:?}"
17554 );
17555 }
17556 }
17557
17558 #[test]
17559 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
17560 let cfg = dead_hub();
17561 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
17562 assert!(
17563 matches!(
17564 propose(&cfg, bad, "intake", "hi"),
17565 Err(LinkError::BadAddress { .. })
17566 ),
17567 "propose must refuse handle {bad:?}"
17568 );
17569 }
17570 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
17571 assert!(matches!(
17572 propose(&cfg, "acme-site", "intake", &oversize),
17573 Err(LinkError::ProposeTooLarge { .. })
17574 ));
17575 assert!(matches!(
17578 propose(&cfg, "acme-site", "intake", "hi"),
17579 Err(LinkError::Transport { .. })
17580 ));
17581 }
17582
17583 #[test]
17584 fn resolve_refuses_a_hand_built_unsafe_address() {
17585 let cfg = dead_hub();
17586 for brain in ["../up", "a/b", "a?x", "a#f"] {
17587 let addr = Address {
17588 brain: brain.to_string(),
17589 target: None,
17590 };
17591 assert!(
17592 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
17593 "resolve must refuse brain {brain:?}"
17594 );
17595 }
17596 for target in [
17597 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
17598 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
17600 AddressTarget::Path("records/x.md#frag".to_string()),
17601 ] {
17602 let addr = Address {
17603 brain: "acme".to_string(),
17604 target: Some(target.clone()),
17605 };
17606 assert!(
17607 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
17608 "resolve must refuse target {target:?}"
17609 );
17610 }
17611 }
17612
17613 #[test]
17614 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
17615 let mut local = std::collections::BTreeMap::new();
17616 local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
17617 local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
17618 let mut remote = std::collections::BTreeMap::new();
17619 remote.insert(
17620 "records/a.md".to_string(),
17621 V2BaselineFile {
17622 sha256: "c".repeat(64),
17623 bytes: 1,
17624 proof: None,
17625 },
17626 );
17627 remote.insert(
17628 "records/b.md".to_string(),
17629 V2BaselineFile {
17630 sha256: "b".repeat(64),
17631 bytes: 1,
17632 proof: None,
17633 },
17634 );
17635 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
17636 }
17637
17638 #[test]
17639 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
17640 let local = std::collections::BTreeMap::new();
17641 let mut remote = std::collections::BTreeMap::new();
17642 remote.insert(
17643 "private/local.md".to_string(),
17644 V2BaselineFile {
17645 sha256: "d".repeat(64),
17646 bytes: 1,
17647 proof: None,
17648 },
17649 );
17650 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
17651 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
17652 }
17653
17654 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
17655 V2VerifiedHead {
17656 requested: TEST_BRAIN_ID.to_string(),
17657 brain_id: TEST_BRAIN_ID.to_string(),
17658 view_kind: "scoped".to_string(),
17659 view_revision: revision.to_string(),
17660 control_revision: revision.to_string(),
17661 identity: V2HeadIdentity {
17662 custody: "hub".to_string(),
17663 fingerprint: "test".to_string(),
17664 public_key_spki: "test".to_string(),
17665 previous: Vec::new(),
17666 rotations: Vec::new(),
17667 },
17668 pointer: None,
17669 trust: TrustState {
17670 v: 2,
17671 origin: "https://hub.example".to_string(),
17672 requested: TEST_BRAIN_ID.to_string(),
17673 brain: TEST_BRAIN_ID.to_string(),
17674 home: None,
17675 anchor: "ed25519:test".to_string(),
17676 current: "ed25519:test".to_string(),
17677 head_seq: 0,
17678 feed_hash: None,
17679 rotations: Vec::new(),
17680 hub_signer: None,
17681 protocol_profile: Some("link-v2".to_string()),
17682 },
17683 alias: None,
17684 }
17685 }
17686
17687 #[test]
17688 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
17689 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
17690 assert!(accepted_as_v2(&trust));
17691
17692 trust.protocol_profile = None;
17693 trust.hub_signer = Some("ed25519:hub".to_string());
17694 assert!(accepted_as_v2(&trust));
17695
17696 trust.hub_signer = None;
17697 assert!(!accepted_as_v2(&trust));
17698 }
17699
17700 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
17701 V2SyncBaseline {
17702 v: 2,
17703 origin: "https://hub.example".to_string(),
17704 brain: TEST_BRAIN_ID.to_string(),
17705 checkout_id: Some("c".repeat(64)),
17706 head_seq: Some(0),
17707 commit_hash: None,
17708 content_root: None,
17709 asset_root: None,
17710 assets: std::collections::BTreeMap::new(),
17711 view_kind: Some("scoped".to_string()),
17712 view_revision: Some(revision.to_string()),
17713 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
17714 files: std::collections::BTreeMap::new(),
17715 local_policy_digest: None,
17716 local_eligibility: std::collections::BTreeMap::new(),
17717 remote_copy_remains: std::collections::BTreeMap::new(),
17718 }
17719 }
17720
17721 #[test]
17722 fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
17723 let cfg = test_hub_config(
17724 "https://hub.example".to_string(),
17725 tempfile::tempdir().unwrap().keep(),
17726 );
17727 let mut baseline = scoped_test_baseline(&"a".repeat(64));
17728 baseline.assets.insert(
17729 "assets/archive.bin".to_string(),
17730 V2BaselineAsset {
17731 blob_sha256: "b".repeat(64),
17732 bytes: MAX_STORE_BYTES + 1,
17733 media_type: "application/octet-stream".to_string(),
17734 wrappers: vec!["records/archive.md".to_string()],
17735 required: true,
17736 disposition: "hosted".to_string(),
17737 leaf_hash: "c".repeat(64),
17738 },
17739 );
17740
17741 let accepted = serde_json::to_vec(&baseline).unwrap();
17742 assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
17743
17744 baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
17745 let refused = serde_json::to_vec(&baseline).unwrap();
17746 assert!(matches!(
17747 parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
17748 Err(LinkError::InvalidFeed { .. })
17749 ));
17750 }
17751
17752 #[test]
17753 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
17754 let directory = tempfile::tempdir().unwrap();
17755 std::fs::write(
17756 directory.path().join("DB.md"),
17757 scoped_projection_bytes(TEST_BRAIN_ID),
17758 )
17759 .unwrap();
17760 let store = Store::open_strict(directory.path()).unwrap();
17761 let head = scoped_test_head(&"a".repeat(64));
17762 let baseline = scoped_test_baseline(&"a".repeat(64));
17763 let mut view = v2_local_files(&store).unwrap();
17764 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
17765 assert!(!view.riding.contains_key("DB.md"));
17766 assert!(!view.eligibility.contains_key("DB.md"));
17767 }
17768
17769 #[test]
17770 fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
17771 let directory = tempfile::tempdir().unwrap();
17772 std::fs::write(
17773 directory.path().join("DB.md"),
17774 scoped_projection_bytes(TEST_BRAIN_ID),
17775 )
17776 .unwrap();
17777 let store = Store::open_strict(directory.path()).unwrap();
17778 let head = scoped_test_head(&"a".repeat(64));
17779 let baseline = scoped_test_baseline(&"a".repeat(64));
17780
17781 let mut carried = v2_local_files(&store).unwrap();
17782 remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
17783 let handed_off =
17784 local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
17785 assert!(!handed_off.riding.contains_key("DB.md"));
17786
17787 let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
17788 assert!(!freshly_scanned.riding.contains_key("DB.md"));
17789
17790 std::fs::write(
17791 directory.path().join("DB.md"),
17792 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
17793 )
17794 .unwrap();
17795 let tampered = Store::open_strict(directory.path()).unwrap();
17796 assert!(matches!(
17797 local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
17798 Err(LinkError::ScopedProjectionModified)
17799 ));
17800 }
17801
17802 #[test]
17803 fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
17804 let directory = tempfile::tempdir().unwrap();
17805 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
17806 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
17807 std::fs::write(
17808 directory.path().join("DB.md"),
17809 b"---\nname: Kept home test\n---\n",
17810 )
17811 .unwrap();
17812 std::fs::write(
17813 directory.path().join("records/notes/a.md"),
17814 b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
17815 )
17816 .unwrap();
17817 std::fs::write(
17818 directory.path().join("sources/private/secret.md"),
17819 b"---\ntype: note\n---\nlocal only\n",
17820 )
17821 .unwrap();
17822 std::fs::write(
17823 directory.path().join("sources/private/unlinked.md"),
17824 b"---\ntype: note\n---\nnot disclosed\n",
17825 )
17826 .unwrap();
17827 std::fs::write(
17828 directory.path().join(".sevralocal"),
17829 b"sources/private/**\n",
17830 )
17831 .unwrap();
17832
17833 let store = Store::open_strict(directory.path()).unwrap();
17834 let view = v2_local_files(&store).unwrap();
17835 assert!(!view.riding.contains_key("sources/private/secret.md"));
17836 assert_eq!(
17837 view.withheld_links,
17838 vec![V2WithheldLink {
17839 source: "records/notes/a.md".to_string(),
17840 target: "sources/private/secret.md".to_string(),
17841 }]
17842 );
17843 }
17844
17845 #[test]
17846 fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
17847 let directory = tempfile::tempdir().unwrap();
17852 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
17853 std::fs::write(
17854 directory.path().join("DB.md"),
17855 b"---\nname: Restored export\n---\n",
17856 )
17857 .unwrap();
17858 std::fs::write(
17859 directory.path().join("records/notes/a.md"),
17860 b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
17861 )
17862 .unwrap();
17863 std::fs::write(
17864 directory.path().join(".sevralocal"),
17865 b"sources/private/**\n",
17866 )
17867 .unwrap();
17868
17869 let store = Store::open_strict(directory.path()).unwrap();
17870 let view = v2_local_files(&store).unwrap();
17871 assert_eq!(
17872 view.withheld_links,
17873 vec![V2WithheldLink {
17874 source: "records/notes/a.md".to_string(),
17875 target: "sources/private/absent.md".to_string(),
17876 }]
17877 );
17878 std::fs::write(
17880 directory.path().join("records/notes/b.md"),
17881 b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
17882 )
17883 .unwrap();
17884 let store = Store::open_strict(directory.path()).unwrap();
17885 let view = v2_local_files(&store).unwrap();
17886 assert!(
17887 !view
17888 .withheld_links
17889 .iter()
17890 .any(|link| link.target == "records/notes/nowhere.md"),
17891 "an unclaimed dangling target must not be declared withheld"
17892 );
17893 }
17894
17895 #[test]
17896 fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
17897 let directory = tempfile::tempdir().unwrap();
17898 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
17899 std::fs::write(
17900 directory.path().join("DB.md"),
17901 b"---\nname: Withdrawal test\n---\n",
17902 )
17903 .unwrap();
17904 let source = b"---\ntype: note\n---\nlocal evidence\n";
17905 std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
17906 std::fs::write(
17907 directory.path().join(".sevralocal"),
17908 b"sources/private/**\n",
17909 )
17910 .unwrap();
17911 let store = Store::open_strict(directory.path()).unwrap();
17912 let view = v2_local_files(&store).unwrap();
17913 let mut remote = std::collections::BTreeMap::new();
17914 remote.insert(
17915 "sources/private/evidence.md".to_string(),
17916 V2BaselineFile {
17917 sha256: content_sha256(source),
17918 bytes: source.len() as u64,
17919 proof: None,
17920 },
17921 );
17922 assert_eq!(
17923 v2_content_withdrawal_operation(
17924 &store,
17925 &view,
17926 &remote,
17927 "sources/private/evidence.md",
17928 "approved retention change",
17929 )
17930 .unwrap(),
17931 json!({
17932 "op": "withdraw_from_hosting",
17933 "path": "sources/private/evidence.md",
17934 "expected": { "kind": "blob", "hash": content_sha256(source) },
17935 "reason": "approved retention change",
17936 })
17937 );
17938
17939 std::fs::write(
17940 directory.path().join("sources/private/evidence.md"),
17941 b"changed after review",
17942 )
17943 .unwrap();
17944 assert!(matches!(
17945 v2_content_withdrawal_operation(
17946 &store,
17947 &view,
17948 &remote,
17949 "sources/private/evidence.md",
17950 "approved retention change",
17951 ),
17952 Err(LinkError::InvalidPack { .. })
17953 ));
17954 }
17955
17956 #[test]
17957 fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
17958 let directory = tempfile::tempdir().unwrap();
17959 std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
17960 std::fs::write(
17961 directory.path().join("DB.md"),
17962 b"---\nname: Asset withdrawal test\n---\n",
17963 )
17964 .unwrap();
17965 let bytes = b"private binary";
17966 std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
17967 std::fs::write(
17968 directory.path().join(".sevralocal"),
17969 b"sources/files/private.pdf\n",
17970 )
17971 .unwrap();
17972 let store = Store::open_strict(directory.path()).unwrap();
17973 let view = v2_local_files(&store).unwrap();
17974 let local = crate::AssetRecord {
17975 path: "sources/files/private.pdf".to_string(),
17976 sha256: content_sha256(bytes),
17977 bytes: bytes.len() as u64,
17978 media_type: "application/pdf".to_string(),
17979 wrappers: vec!["sources/files/private.md".to_string()],
17980 required: true,
17981 };
17982 let current = V2BaselineAsset {
17983 blob_sha256: local.sha256.clone(),
17984 bytes: local.bytes,
17985 media_type: local.media_type.clone(),
17986 wrappers: local.wrappers.clone(),
17987 required: local.required,
17988 disposition: "hosted".to_string(),
17989 leaf_hash: "d".repeat(64),
17990 };
17991 assert_eq!(
17992 v2_asset_withdrawal_operation(
17993 &store,
17994 &view,
17995 &local.path,
17996 &local,
17997 ¤t,
17998 "approved retention change",
17999 )
18000 .unwrap(),
18001 json!({
18002 "op": "asset_withdraw",
18003 "path": local.path,
18004 "expected": { "kind": "asset", "hash": "d".repeat(64) },
18005 "reason": "approved retention change",
18006 })
18007 );
18008
18009 let mut mismatched = current.clone();
18010 mismatched.required = false;
18011 assert!(matches!(
18012 v2_asset_withdrawal_operation(
18013 &store,
18014 &view,
18015 &local.path,
18016 &local,
18017 &mismatched,
18018 "approved retention change",
18019 ),
18020 Err(LinkError::InvalidPack { .. })
18021 ));
18022 }
18023
18024 #[test]
18025 fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
18026 let first = v2_checkout_id(None).unwrap();
18027 assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
18028 assert_ne!(first, v2_checkout_id(None).unwrap());
18029 assert!(is_sha256(&first));
18030 }
18031
18032 #[test]
18033 fn scoped_projection_edit_and_scope_transition_fail_closed() {
18034 let directory = tempfile::tempdir().unwrap();
18035 std::fs::write(
18036 directory.path().join("DB.md"),
18037 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
18038 )
18039 .unwrap();
18040 let store = Store::open_strict(directory.path()).unwrap();
18041 let head = scoped_test_head(&"a".repeat(64));
18042 let baseline = scoped_test_baseline(&"a".repeat(64));
18043 let mut view = v2_local_files(&store).unwrap();
18044 assert!(matches!(
18045 remove_scoped_projection(&head, Some(&baseline), &mut view),
18046 Err(LinkError::ScopedProjectionModified)
18047 ));
18048
18049 let changed = scoped_test_head(&"b".repeat(64));
18050 assert!(matches!(
18051 ensure_v2_view_compatible(&changed, Some(&baseline)),
18052 Err(LinkError::ScopedViewChanged)
18053 ));
18054
18055 let mut same_view_new_control = head.clone();
18056 same_view_new_control.control_revision = "c".repeat(64);
18057 assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
18058 assert!(!same_v2_head(&head, &same_view_new_control));
18059 }
18060
18061 #[test]
18062 fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
18063 let scoped = scoped_test_head(&"a".repeat(64));
18064 let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
18065 assert!(matches!(
18066 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
18067 Err(LinkError::ScopedProjectionModified)
18068 ));
18069
18070 let mut full = scoped.clone();
18071 full.view_kind = "full".to_string();
18072 let mut full_baseline = scoped_baseline.clone();
18073 full_baseline.view_kind = Some("full".to_string());
18074 full_baseline.projection_sha256 = None;
18075 assert!(matches!(
18076 ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
18077 Err(LinkError::InvalidPack { .. })
18078 ));
18079
18080 assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
18081 assert!(
18082 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
18083 );
18084 }
18085
18086 #[test]
18087 fn scoped_view_metadata_is_explicitly_non_authoritative() {
18088 let head = scoped_test_head(&"a".repeat(64));
18089 let value: Value =
18090 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
18091 assert_eq!(value["kind"], "link.md-scoped-view");
18092 assert_eq!(value["authoritative"], false);
18093 assert_eq!(value["visible_files"], 7);
18094 assert_eq!(value["brain"], TEST_BRAIN_ID);
18095 }
18096
18097 #[test]
18098 fn local_scoped_marker_requires_the_exact_generated_projection() {
18099 let directory = tempfile::tempdir().unwrap();
18100 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
18101 std::fs::write(
18102 directory.path().join("DB.md"),
18103 scoped_projection_bytes(TEST_BRAIN_ID),
18104 )
18105 .unwrap();
18106 let head = scoped_test_head(&"a".repeat(64));
18107 std::fs::write(
18108 directory.path().join(".dbmd/view.json"),
18109 scoped_view_metadata(&head, 0).unwrap(),
18110 )
18111 .unwrap();
18112 let store = Store::open_strict(directory.path()).unwrap();
18113 assert!(has_verified_local_scoped_view(&store));
18114
18115 std::fs::write(
18116 directory.path().join("DB.md"),
18117 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18118 )
18119 .unwrap();
18120 let altered = Store::open_strict(directory.path()).unwrap();
18121 assert!(!has_verified_local_scoped_view(&altered));
18122 }
18123
18124 fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
18125 use ring::signature::KeyPair as _;
18126
18127 let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
18128 let rng = ring::rand::SystemRandom::new();
18129 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18130 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18131 let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
18132 let public_key = URL_SAFE_NO_PAD.encode(&public_der);
18133 let fingerprint = format!("{:x}", Sha256::digest(&public_der));
18134 let blob = b"new";
18135 let blob_hash = content_sha256(blob);
18136 let changes = json!({
18137 "mutation_id": "sync:proposal-fixture",
18138 "operations": [{
18139 "blob": blob_hash,
18140 "bytes": blob.len(),
18141 "expected": null,
18142 "op": "put",
18143 "path": "records/new.md",
18144 }],
18145 "reason": "fixture",
18146 "v": 2,
18147 });
18148 let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
18149 let changes_base64 = STANDARD.encode(&changes_bytes);
18150 let descriptor = json!({
18151 "base": null,
18152 "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
18153 "changes_base64": changes_base64,
18154 "rebase": "strict",
18155 "v": 2,
18156 });
18157 let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
18158 let payload_hash = "b".repeat(64);
18159 let submitted_at = "2026-08-19T12:00:00.000Z";
18160 let claim = json!({
18161 "actor_root": {
18162 "actor_class": "foreign_key",
18163 "credential": "ed25519:fixture",
18164 "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
18165 "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
18166 "principal": "key:fixture",
18167 "role": null,
18168 },
18169 "brain": TEST_BRAIN_ID,
18170 "clear_sha256": clear_hash,
18171 "control_revision": "c".repeat(64),
18172 "mutation_id": "sync:proposal-fixture",
18173 "payload_sha256": payload_hash,
18174 "proposal_id": proposal_id,
18175 "submitted_at": submitted_at,
18176 "v": 2,
18177 });
18178 let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
18179 let envelope = json!({
18180 "claim": claim,
18181 "fingerprint": fingerprint,
18182 "public_key": public_key,
18183 "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
18184 });
18185 let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18186 let submission_hash =
18187 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
18188 let mut head = scoped_test_head(&"c".repeat(64));
18189 head.view_kind = "full".to_string();
18190 head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
18191 let value = json!({
18192 "proposal": {
18193 "base": null,
18194 "blobs": [{
18195 "bytes": blob.len(),
18196 "endpoint": format!(
18197 "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
18198 ),
18199 "sha256": blob_hash,
18200 }],
18201 "changes_base64": changes_base64,
18202 "clear_sha256": clear_hash,
18203 "expires_at": "2026-08-26T12:00:00.000Z",
18204 "id": proposal_id,
18205 "payload_sha256": payload_hash,
18206 "proposer": { "class": "foreign_key" },
18207 "rebase": "strict",
18208 "state": "pending",
18209 "submission_claim_base64": STANDARD.encode(envelope_bytes),
18210 "submission_claim_sha256": submission_hash,
18211 "submitted_at": submitted_at,
18212 },
18213 "v": 2,
18214 });
18215 (head, proposal_id, value)
18216 }
18217
18218 #[test]
18219 fn v2_proposal_verifier_accepts_exact_signed_payload() {
18220 let (head, proposal_id, value) = signed_proposal_fixture();
18221 let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
18222 assert_eq!(verified.blobs.len(), 1);
18223 assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
18224 }
18225
18226 #[test]
18227 fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
18228 let (head, proposal_id, value) = signed_proposal_fixture();
18229
18230 let mut changed = value.clone();
18231 changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
18232 assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
18233
18234 let mut redirected = value.clone();
18235 redirected["proposal"]["blobs"][0]["endpoint"] =
18236 Value::String("https://attacker.example/blob".to_string());
18237 assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
18238
18239 let mut forged = value;
18240 let encoded = forged["proposal"]["submission_claim_base64"]
18241 .as_str()
18242 .unwrap();
18243 let mut envelope: Value =
18244 serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
18245 envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
18246 let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18247 forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
18248 forged["proposal"]["submission_claim_sha256"] = Value::String(
18249 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
18250 );
18251 assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
18252 }
18253
18254 #[cfg(unix)]
18255 #[test]
18256 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
18257 let sandbox = tempfile::tempdir().unwrap();
18258 let destination = sandbox.path().join("brain");
18259 let entries = vec![
18260 (
18261 "DB.md".to_string(),
18262 scoped_projection_bytes(TEST_BRAIN_ID),
18263 ),
18264 (
18265 "records/contacts/a.md".to_string(),
18266 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
18267 .to_vec(),
18268 ),
18269 ];
18270 install_pulled_delta(&destination, &entries, &[], true).unwrap();
18271 assert!(destination.join("index.md").is_file());
18272 assert!(destination.join("records/index.md").is_file());
18273 assert!(destination.join("records/contacts/index.md").is_file());
18274 assert!(destination.join("records/contacts/index.jsonl").is_file());
18275 }
18276
18277 #[cfg(unix)]
18278 #[test]
18279 fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
18280 let sandbox = tempfile::tempdir().unwrap();
18281 let destination = sandbox.path().join("brain");
18282 let cache = sandbox.path().join("cache");
18283 std::fs::create_dir(&cache).unwrap();
18284 let db = scoped_projection_bytes(TEST_BRAIN_ID);
18285 let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
18286 let db_source = cache.join("db");
18287 let shared_source = cache.join("shared");
18288 crate::fsx::write_atomic(&db_source, &db).unwrap();
18289 crate::fsx::write_atomic(&shared_source, shared).unwrap();
18290 let mut entries = vec![V2StagedFile {
18291 path: "DB.md".to_string(),
18292 source: db_source,
18293 sha256: content_sha256(&db),
18294 bytes: db.len() as u64,
18295 }];
18296 for index in 0..512 {
18297 entries.push(V2StagedFile {
18298 path: format!("records/items/{index:05}.md"),
18299 source: shared_source.clone(),
18300 sha256: content_sha256(shared),
18301 bytes: shared.len() as u64,
18302 });
18303 }
18304 install_pulled_delta_sources(
18305 &destination,
18306 &entries,
18307 &[],
18308 false,
18309 None,
18310 &scoped_test_head(&"c".repeat(64)),
18311 )
18312 .unwrap();
18313 assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
18314 for index in 0..512 {
18315 assert_eq!(
18316 std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
18317 shared
18318 );
18319 }
18320 assert!(
18321 std::fs::read_dir(sandbox.path())
18322 .unwrap()
18323 .all(|entry| !entry
18324 .unwrap()
18325 .file_name()
18326 .to_string_lossy()
18327 .contains("pull-stage")),
18328 "the private stage must be atomically installed or removed"
18329 );
18330 }
18331
18332 #[cfg(unix)]
18333 #[test]
18334 fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
18335 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
18336
18337 let sandbox = tempfile::tempdir().unwrap();
18338 let root = sandbox.path().join("brain");
18339 std::fs::create_dir_all(root.join("records/items")).unwrap();
18340 let db = scoped_projection_bytes(TEST_BRAIN_ID);
18341 let old = b"---\ntype: note\n---\n\nold\n";
18342 let new = b"---\ntype: note\n---\n\nnew\n";
18343 let removed = b"---\ntype: note\n---\n\nremove me\n";
18344 std::fs::write(root.join("DB.md"), &db).unwrap();
18345 std::fs::write(root.join("records/items/change.md"), old).unwrap();
18346 std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
18347 for index in 0..512 {
18348 std::fs::write(
18349 root.join(format!("records/items/untouched-{index:04}.md")),
18350 old,
18351 )
18352 .unwrap();
18353 }
18354 let untouched = root.join("records/items/untouched-0256.md");
18355 let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
18356 let source = sandbox.path().join("changed-source");
18357 crate::fsx::write_atomic(&source, new).unwrap();
18358 let same_source = sandbox.path().join("unchanged-source");
18359 crate::fsx::write_atomic(&same_source, old).unwrap();
18360 let same_entry = V2StagedFile {
18361 path: "records/items/change.md".to_string(),
18362 source: same_source,
18363 sha256: content_sha256(old),
18364 bytes: old.len() as u64,
18365 };
18366 let entry = V2StagedFile {
18367 path: "records/items/change.md".to_string(),
18368 source,
18369 sha256: content_sha256(new),
18370 bytes: new.len() as u64,
18371 };
18372 let head = scoped_test_head(&"c".repeat(64));
18373
18374 install_established_v2_delta(
18378 Store::open_strict(&root).unwrap(),
18379 &[same_entry],
18380 &["records/items/already-absent.md".to_string()],
18381 true,
18382 None,
18383 &head,
18384 )
18385 .unwrap();
18386 assert_eq!(
18387 std::fs::metadata(&untouched).unwrap().ino(),
18388 untouched_inode
18389 );
18390 assert!(!root.join(V2_PULL_JOURNAL).exists());
18391
18392 install_established_v2_delta(
18393 Store::open_strict(&root).unwrap(),
18394 &[entry],
18395 &["records/items/delete.md".to_string()],
18396 false,
18397 None,
18398 &head,
18399 )
18400 .unwrap();
18401 assert_eq!(
18402 std::fs::read(root.join("records/items/change.md")).unwrap(),
18403 new
18404 );
18405 assert!(!root.join("records/items/delete.md").exists());
18406 assert_eq!(
18407 std::fs::metadata(&untouched).unwrap().ino(),
18408 untouched_inode
18409 );
18410 assert!(root.join(V2_PULL_JOURNAL).is_file());
18411 assert_eq!(
18412 std::fs::metadata(root.join(V2_PULL_JOURNAL))
18413 .unwrap()
18414 .permissions()
18415 .mode()
18416 & 0o777,
18417 0o600
18418 );
18419 let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
18420 .unwrap()
18421 .unwrap();
18422 assert_eq!(
18423 std::fs::metadata(root.join(&journal.backup_dir))
18424 .unwrap()
18425 .permissions()
18426 .mode()
18427 & 0o777,
18428 0o700
18429 );
18430 for entry in &journal.entries {
18431 if let Some(backup) = &entry.backup {
18432 assert_eq!(
18433 std::fs::metadata(root.join(&journal.backup_dir).join(backup))
18434 .unwrap()
18435 .permissions()
18436 .mode()
18437 & 0o777,
18438 0o600
18439 );
18440 }
18441 }
18442
18443 let cfg = test_hub_config(
18444 "https://example.test".to_string(),
18445 sandbox.path().join("state"),
18446 );
18447 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18448 assert_eq!(
18449 std::fs::read(root.join("records/items/change.md")).unwrap(),
18450 old
18451 );
18452 assert_eq!(
18453 std::fs::read(root.join("records/items/delete.md")).unwrap(),
18454 removed
18455 );
18456 assert_eq!(
18457 std::fs::metadata(&untouched).unwrap().ino(),
18458 untouched_inode
18459 );
18460 assert!(!root.join(V2_PULL_JOURNAL).exists());
18461 }
18462
18463 #[test]
18464 fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
18465 let body = b"bounded bytes";
18466 let path = "records/example.md".to_string();
18467 let file = V2BaselineFile {
18468 sha256: content_sha256(body),
18469 bytes: body.len() as u64,
18470 proof: None,
18471 };
18472 let header = serde_json::to_vec(&json!({
18473 "bytes": body.len(),
18474 "path": path,
18475 "sha256": file.sha256,
18476 "v": 2,
18477 }))
18478 .unwrap();
18479 let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
18480 stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
18481 stream.extend_from_slice(&header);
18482 stream.extend_from_slice(body);
18483 stream.extend_from_slice(&0_u32.to_be_bytes());
18484 let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
18485 assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
18486
18487 let mut tampered = stream.clone();
18488 let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
18489 tampered[body_offset] ^= 1;
18490 assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
18491
18492 let mut trailing = stream;
18493 trailing.push(0);
18494 assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
18495 }
18496
18497 #[test]
18498 fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
18499 let sandbox = tempfile::TempDir::new().unwrap();
18500 let root = sandbox.path().join("brain");
18501 std::fs::create_dir_all(&root).unwrap();
18502 std::fs::write(
18503 root.join("DB.md"),
18504 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18505 )
18506 .unwrap();
18507 let store = Store::open_strict(&root).unwrap();
18508 let incomplete = crate::ulid::mint();
18509 store
18510 .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
18511 .unwrap();
18512 let expired = crate::ulid::mint();
18513 store
18514 .create_dir_all(&v2_conflict_relative(&expired, "files"))
18515 .unwrap();
18516 let plan = V2ConflictPlan {
18517 v: 2,
18518 class: "content_resolution_required".to_string(),
18519 bundle: expired.clone(),
18520 brain: TEST_BRAIN_ID.to_string(),
18521 origin: "https://example.test".to_string(),
18522 created_unix: 0,
18523 expires_unix: 0,
18524 base_seq: None,
18525 base_commit: None,
18526 remote_seq: 0,
18527 remote_commit: None,
18528 remote_content_root: None,
18529 view_kind: "full".to_string(),
18530 view_revision: "a".repeat(64),
18531 files: vec![V2ConflictFile {
18532 path: "records/value.md".to_string(),
18533 base: V2ConflictCoordinate {
18534 sha256: None,
18535 bytes: None,
18536 file: None,
18537 },
18538 local: V2ConflictCoordinate {
18539 sha256: None,
18540 bytes: None,
18541 file: None,
18542 },
18543 remote: V2ConflictCoordinate {
18544 sha256: None,
18545 bytes: None,
18546 file: None,
18547 },
18548 }],
18549 };
18550 let mut bytes = serde_json::to_vec(&plan).unwrap();
18551 bytes.push(b'\n');
18552 store
18553 .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
18554 .unwrap();
18555
18556 let listed = sync_conflicts(&root, false, false).unwrap();
18557 assert_eq!(listed["bundles"], 2);
18558 assert_eq!(listed["pruned"], 0);
18559 let pruned = sync_conflicts(&root, true, false).unwrap();
18560 assert_eq!(pruned["bundles"], 0);
18561 assert_eq!(pruned["pruned"], 2);
18562 assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
18563 assert!(!root.join(".dbmd/conflicts").join(expired).exists());
18564 }
18565
18566 #[test]
18567 fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
18568 let sandbox = tempfile::TempDir::new().unwrap();
18569 let root = sandbox.path().join("brain");
18570 std::fs::create_dir_all(&root).unwrap();
18571 std::fs::write(
18572 root.join("DB.md"),
18573 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18574 )
18575 .unwrap();
18576 let store = Store::open_strict(&root).unwrap();
18577 let bundle = crate::ulid::mint();
18578 store
18579 .create_dir_all(&v2_conflict_relative(&bundle, "files"))
18580 .unwrap();
18581 store
18582 .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
18583 .unwrap();
18584
18585 assert!(sync_conflicts(&root, true, false).is_err());
18586 assert!(sync_conflicts(&root, false, true).is_err());
18587 let pruned = sync_conflicts(&root, true, true).unwrap();
18588 assert_eq!(pruned["pruned"], 1);
18589 assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
18590 }
18591
18592 #[test]
18593 fn ready_pull_journal_rolls_back_exact_preimages() {
18594 let sandbox = tempfile::TempDir::new().unwrap();
18595 let root = sandbox.path().join("brain");
18596 std::fs::create_dir_all(root.join("records")).unwrap();
18597 std::fs::write(
18598 root.join("DB.md"),
18599 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18600 )
18601 .unwrap();
18602 let path = "records/value.md";
18603 let old = b"---\ntype: note\n---\n\nold\n";
18604 let new = b"---\ntype: note\n---\n\nnew\n";
18605 std::fs::write(root.join(path), old).unwrap();
18606 let store = Store::open_strict(&root).unwrap();
18607 let bundle = crate::ulid::mint();
18608 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18609 store
18610 .create_private_dir_all(Path::new(&backup_dir))
18611 .unwrap();
18612 store
18613 .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
18614 .unwrap();
18615 let journal = V2PullJournal {
18616 v: 1,
18617 phase: V2PullPhase::Ready,
18618 brain: TEST_BRAIN_ID.to_string(),
18619 previous: V2PullCoordinate {
18620 head_seq: None,
18621 commit_hash: None,
18622 view_kind: None,
18623 view_revision: None,
18624 },
18625 next: V2PullCoordinate {
18626 head_seq: Some(2),
18627 commit_hash: Some("c".repeat(64)),
18628 view_kind: Some("full".to_string()),
18629 view_revision: Some("d".repeat(64)),
18630 },
18631 backup_dir: backup_dir.clone(),
18632 entries: vec![V2PullJournalEntry {
18633 path: path.to_string(),
18634 old: Some(V2PullFileCoordinate {
18635 sha256: content_sha256(old),
18636 bytes: old.len() as u64,
18637 }),
18638 new: Some(V2PullFileCoordinate {
18639 sha256: content_sha256(new),
18640 bytes: new.len() as u64,
18641 }),
18642 backup: Some("00000000".to_string()),
18643 }],
18644 };
18645 validate_v2_pull_journal(&journal).unwrap();
18646 store
18647 .write_private_atomic_new(
18648 Path::new(V2_PULL_JOURNAL),
18649 &v2_pull_journal_bytes(&journal).unwrap(),
18650 )
18651 .unwrap();
18652 store.write_atomic(Path::new(path), new).unwrap();
18653
18654 let cfg = test_hub_config(
18655 "https://example.test".to_string(),
18656 sandbox.path().join("state"),
18657 );
18658 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18659 assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
18660 assert!(!root.join(V2_PULL_JOURNAL).exists());
18661 assert!(!root.join(backup_dir).exists());
18662 }
18663
18664 #[test]
18665 fn preparing_pull_journal_discards_only_private_staging() {
18666 let sandbox = tempfile::TempDir::new().unwrap();
18667 let root = sandbox.path().join("brain");
18668 std::fs::create_dir_all(root.join(".dbmd")).unwrap();
18669 std::fs::write(
18670 root.join("DB.md"),
18671 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18672 )
18673 .unwrap();
18674 let store = Store::open_strict(&root).unwrap();
18675 let bundle = crate::ulid::mint();
18676 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18677 store
18678 .create_private_dir_all(Path::new(&backup_dir))
18679 .unwrap();
18680 let journal = V2PullJournal {
18681 v: 1,
18682 phase: V2PullPhase::Preparing,
18683 brain: TEST_BRAIN_ID.to_string(),
18684 previous: V2PullCoordinate {
18685 head_seq: None,
18686 commit_hash: None,
18687 view_kind: None,
18688 view_revision: None,
18689 },
18690 next: V2PullCoordinate {
18691 head_seq: Some(1),
18692 commit_hash: Some("a".repeat(64)),
18693 view_kind: Some("full".to_string()),
18694 view_revision: Some("b".repeat(64)),
18695 },
18696 backup_dir: backup_dir.clone(),
18697 entries: vec![V2PullJournalEntry {
18698 path: "records/new.md".to_string(),
18699 old: None,
18700 new: Some(V2PullFileCoordinate {
18701 sha256: "c".repeat(64),
18702 bytes: 1,
18703 }),
18704 backup: None,
18705 }],
18706 };
18707 store
18708 .write_private_atomic_new(
18709 Path::new(V2_PULL_JOURNAL),
18710 &v2_pull_journal_bytes(&journal).unwrap(),
18711 )
18712 .unwrap();
18713 let cfg = test_hub_config(
18714 "https://example.test".to_string(),
18715 sandbox.path().join("state"),
18716 );
18717
18718 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18719
18720 assert!(root.join("DB.md").is_file());
18721 assert!(!root.join(V2_PULL_JOURNAL).exists());
18722 assert!(!root.join(backup_dir).exists());
18723 }
18724
18725 #[test]
18726 fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
18727 let sandbox = tempfile::TempDir::new().unwrap();
18728 let root = sandbox.path().join("brain");
18729 std::fs::create_dir_all(root.join("records")).unwrap();
18730 std::fs::write(
18731 root.join("DB.md"),
18732 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18733 )
18734 .unwrap();
18735 let new = b"---\ntype: note\n---\n\nnew\n";
18736 std::fs::write(root.join("records/value.md"), new).unwrap();
18737 let store = Store::open_strict(&root).unwrap();
18738 let bundle = crate::ulid::mint();
18739 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18740 store
18741 .create_private_dir_all(Path::new(&backup_dir))
18742 .unwrap();
18743 let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
18744 store.create_private_dir_all(Path::new(&orphan)).unwrap();
18745 let next = V2PullCoordinate {
18746 head_seq: Some(2),
18747 commit_hash: Some("c".repeat(64)),
18748 view_kind: Some("full".to_string()),
18749 view_revision: Some("d".repeat(64)),
18750 };
18751 let journal = V2PullJournal {
18752 v: 1,
18753 phase: V2PullPhase::Ready,
18754 brain: TEST_BRAIN_ID.to_string(),
18755 previous: V2PullCoordinate {
18756 head_seq: Some(1),
18757 commit_hash: Some("a".repeat(64)),
18758 view_kind: Some("full".to_string()),
18759 view_revision: Some("b".repeat(64)),
18760 },
18761 next: next.clone(),
18762 backup_dir: backup_dir.clone(),
18763 entries: vec![V2PullJournalEntry {
18764 path: "records/value.md".to_string(),
18765 old: Some(V2PullFileCoordinate {
18766 sha256: "e".repeat(64),
18767 bytes: new.len() as u64,
18768 }),
18769 new: Some(V2PullFileCoordinate {
18770 sha256: content_sha256(new),
18771 bytes: new.len() as u64,
18772 }),
18773 backup: Some("00000000".to_string()),
18774 }],
18775 };
18776 store
18777 .write_private_atomic_new(
18778 Path::new(V2_PULL_JOURNAL),
18779 &v2_pull_journal_bytes(&journal).unwrap(),
18780 )
18781 .unwrap();
18782 let cfg = test_hub_config(
18783 "https://example.test".to_string(),
18784 sandbox.path().join("state"),
18785 );
18786 save_v2_baseline(
18787 &cfg,
18788 TEST_BRAIN_ID,
18789 &root,
18790 &V2SyncBaseline {
18791 v: 2,
18792 origin: "https://example.test".to_string(),
18793 brain: TEST_BRAIN_ID.to_string(),
18794 checkout_id: Some("c".repeat(64)),
18795 head_seq: next.head_seq,
18796 commit_hash: next.commit_hash.clone(),
18797 content_root: Some("f".repeat(64)),
18798 asset_root: None,
18799 assets: Default::default(),
18800 view_kind: next.view_kind.clone(),
18801 view_revision: next.view_revision.clone(),
18802 projection_sha256: None,
18803 files: Default::default(),
18804 local_policy_digest: None,
18805 local_eligibility: Default::default(),
18806 remote_copy_remains: Default::default(),
18807 },
18808 )
18809 .unwrap();
18810
18811 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18812
18813 assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
18814 assert!(!root.join(V2_PULL_JOURNAL).exists());
18815 assert!(!root.join(backup_dir).exists());
18816 assert!(!root.join(orphan).exists());
18817 }
18818}