1use std::collections::BTreeMap;
5use std::fs;
6use std::io::Read;
7#[cfg(unix)]
8use std::io::Write;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::core::agents::FileLock;
14use crate::core::work_graph::BoundedWorkGraph;
15
16const STORE_SCHEMA_VERSION: u16 = 1;
17const MAX_STORE_BYTES: u64 = 4 * 1024 * 1024;
18const MAX_GRAPHS: usize = 32;
19
20#[cfg(any(test, feature = "test-support"))]
21thread_local! {
22 static FAIL_NEXT_SAVE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
23}
24
25#[cfg(any(test, feature = "test-support"))]
26pub fn with_save_failure<T>(action: impl FnOnce() -> T) -> T {
27 struct Reset;
28 impl Drop for Reset {
29 fn drop(&mut self) {
30 FAIL_NEXT_SAVE.set(false);
31 }
32 }
33 let _reset = Reset;
34 FAIL_NEXT_SAVE.set(true);
35 action()
36}
37
38#[derive(Debug, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct WorkGraphStore {
41 schema_version: u16,
42 project_root_hash: String,
43 graphs: BTreeMap<String, BoundedWorkGraph>,
44}
45
46pub enum ClaimNodeExecution {
47 Claimed(Box<crate::core::work_graph::WorkNode>),
48 RecoveredExpired,
49}
50
51impl WorkGraphStore {
52 fn empty(project_root: &str) -> Self {
53 Self {
54 schema_version: STORE_SCHEMA_VERSION,
55 project_root_hash: crate::core::project_hash::hash_project_root(project_root),
56 graphs: BTreeMap::new(),
57 }
58 }
59
60 pub fn graph(&self, graph_id: &str) -> Option<&BoundedWorkGraph> {
61 self.graphs.get(graph_id)
62 }
63
64 pub fn child_delivery_authority(
67 &self,
68 certificate: &crate::core::a2a::task::delivery_delegation::DeliveryDelegationV1,
69 policy: &crate::core::a2a::task::TaskAuthorityConfigV1,
70 expected: &crate::core::a2a::task::TaskAuthorityExpectationV1<'_>,
71 ) -> Result<crate::core::a2a::task::TaskAuthorityConfigV1, String> {
72 use crate::core::a2a::task::delivery_delegation::DeliveryExecutionBindingV1;
73 let graph = self
74 .graph(&certificate.execution.graph_id)
75 .ok_or("delegated graph is missing")?;
76 let node = graph
77 .get_node(&certificate.execution.node_id)
78 .ok_or("delegated node is missing")?;
79 let now_ms = u64::try_from(expected.now.timestamp_millis())
80 .map_err(|_| "invalid verification time")?;
81 if node.status != crate::core::work_graph::NodeStatus::Active
82 || !node.execution_started
83 || node
84 .lease_expires_epoch_ms
85 .is_none_or(|expiry| expiry <= now_ms)
86 {
87 return Err("delegated execution is not live".into());
88 }
89 let fence = node
90 .execution_fence
91 .as_deref()
92 .ok_or("execution fence missing")?;
93 let attempt = node.delivery_attempt.ok_or("execution attempt missing")?;
94 let task = crate::core::work_graph_delegation::execution_key_for_scope(
95 &self.project_root_hash,
96 graph.graph_id(),
97 &node.node_id,
98 fence,
99 )?;
100 let live = DeliveryExecutionBindingV1 {
101 graph_id: graph.graph_id().into(),
102 node_id: node.node_id.clone(),
103 fence: fence.into(),
104 task_id: crate::core::work_graph_delegation::attempt_task_id(&task, attempt),
105 attempt,
106 };
107 let host = graph
108 .root_agent_for(&node.node_id)
109 .map_err(|error| error.to_string())?;
110 let expected_host = crate::core::a2a::task::TaskAuthorityExpectationV1 {
111 sender: host,
112 recipient: expected.recipient,
113 tenant_id: expected.tenant_id,
114 project_id: expected.project_id,
115 now: expected.now,
116 };
117 certificate
118 .verify_for_execution(policy, &expected_host, &live, &node.agent_id)
119 .map_err(|error| error.to_string())
120 }
121
122 pub fn graph_revision(&self, graph_id: &str) -> Result<String, String> {
125 validate_id(graph_id, "graph_id")?;
126 let graph = self
127 .graph(graph_id)
128 .ok_or_else(|| format!("graph not found: {graph_id}"))?;
129 let bytes = serde_json::to_vec(&(&self.project_root_hash, graph_id, graph))
130 .map_err(|error| error.to_string())?;
131 let mut hash = blake3::Hasher::new();
132 hash.update(b"lean-ctx:work-graph-write-revision:v1\0");
133 hash.update(&bytes);
134 Ok(hash.finalize().to_hex().to_string())
135 }
136
137 pub fn mutate_graph_if_revision<T>(
140 project_root: &str,
141 graph_id: &str,
142 expected_revision: &str,
143 mutate: impl FnOnce(&mut BoundedWorkGraph) -> Result<T, String>,
144 ) -> Result<T, String> {
145 Self::mutate_if_revision(project_root, graph_id, expected_revision, |store| {
146 mutate(store.graph_mut(graph_id)?)
147 })
148 }
149
150 pub fn mutate_if_revision<T>(
153 project_root: &str,
154 graph_id: &str,
155 expected_revision: &str,
156 mutate: impl FnOnce(&mut Self) -> Result<T, String>,
157 ) -> Result<T, String> {
158 if expected_revision.len() != 64
159 || !expected_revision
160 .bytes()
161 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
162 {
163 return Err("invalid work graph write revision".into());
164 }
165 Self::mutate(project_root, |store| {
166 if store.graph_revision(graph_id)? != expected_revision {
167 return Err("work graph revision conflict".into());
168 }
169 mutate(store)
170 })
171 }
172
173 pub fn graph_ids(&self) -> impl Iterator<Item = &str> {
174 self.graphs.keys().map(String::as_str)
175 }
176
177 pub fn graph_ids_for_agent<'a>(&'a self, agent_id: &'a str) -> impl Iterator<Item = &'a str> {
178 self.graphs
179 .iter()
180 .filter(move |(_, graph)| graph.contains_agent(agent_id))
181 .map(|(graph_id, _)| graph_id.as_str())
182 }
183
184 pub fn create(&mut self, graph_id: &str, mut graph: BoundedWorkGraph) -> Result<(), String> {
185 validate_id(graph_id, "graph_id")?;
186 if self.graphs.contains_key(graph_id) {
187 return Err(format!(
188 "duplicate graph: {graph_id} (inspect it with action=get or choose a new graph_id)"
189 ));
190 }
191 if self.graphs.len() >= MAX_GRAPHS {
192 return Err(format!("work graph store at capacity ({MAX_GRAPHS})"));
193 }
194 graph
195 .bind_graph_id(graph_id)
196 .map_err(|error| error.to_string())?;
197 self.graphs.insert(graph_id.to_string(), graph);
198 Ok(())
199 }
200
201 pub fn graph_mut(&mut self, graph_id: &str) -> Result<&mut BoundedWorkGraph, String> {
202 validate_id(graph_id, "graph_id")?;
203 self.graphs
204 .get_mut(graph_id)
205 .ok_or_else(|| format!("graph not found: {graph_id}"))
206 }
207
208 pub fn claim_node_execution(
209 &mut self,
210 graph_id: &str,
211 node_id: &str,
212 now_epoch_ms: u64,
213 lease_expires_epoch_ms: u64,
214 ) -> Result<ClaimNodeExecution, String> {
215 validate_id(graph_id, "graph_id")?;
216 validate_id(node_id, "node_id")?;
217 let requested = self
218 .graph(graph_id)
219 .and_then(|graph| graph.get_node(node_id))
220 .ok_or_else(|| format!("node not found: {node_id}"))?
221 .path_claims
222 .clone();
223 if !requested.is_empty()
224 && self.graphs.iter().any(|(candidate_graph_id, graph)| {
225 graph.has_live_path_conflict(
226 &requested,
227 now_epoch_ms,
228 (candidate_graph_id == graph_id).then_some(node_id),
229 )
230 })
231 {
232 return Err("path claim conflicts with a live work graph execution".into());
233 }
234 let graph = self.graph_mut(graph_id)?;
235 let stopped = graph
238 .recover_expired_execution(node_id, now_epoch_ms)
239 .map_err(|error| error.to_string())?;
240 if !stopped.is_empty() {
241 return Ok(ClaimNodeExecution::RecoveredExpired);
242 }
243 graph
244 .claim_or_resume_active_until(node_id, lease_expires_epoch_ms)
245 .cloned()
246 .map(Box::new)
247 .map(ClaimNodeExecution::Claimed)
248 .map_err(|error| error.to_string())
249 }
250
251 pub fn load(project_root: &str) -> Result<Self, String> {
252 let path = store_path(project_root)?;
253 let parent = path
254 .parent()
255 .ok_or_else(|| "invalid work graph path".to_string())?;
256 fs::create_dir_all(parent).map_err(|error| error.to_string())?;
257 reject_symlink(parent)?;
258 let _lock = FileLock::acquire(&path.with_extension("lock"))?;
259 load_at(&path, project_root)
260 }
261
262 pub fn mutate<T>(
263 project_root: &str,
264 mutate: impl FnOnce(&mut Self) -> Result<T, String>,
265 ) -> Result<T, String> {
266 let path = store_path(project_root)?;
267 let parent = path
268 .parent()
269 .ok_or_else(|| "invalid work graph path".to_string())?;
270 fs::create_dir_all(parent).map_err(|error| error.to_string())?;
271 reject_symlink(parent)?;
272 #[cfg(unix)]
273 let directory = open_parent_directory(&path).map_err(|error| error.to_string())?;
274 let _lock = FileLock::acquire(&path.with_extension("lock"))?;
275 #[cfg(unix)]
276 let mut store = load_in_directory(&directory, &path, project_root)?;
277 #[cfg(not(unix))]
278 let mut store = load_at(&path, project_root)?;
279 let result = mutate(&mut store)?;
280 store.validate()?;
281 #[cfg(any(test, feature = "test-support"))]
282 if FAIL_NEXT_SAVE.replace(false) {
283 return Err("injected work graph persistence failure".into());
284 }
285 #[cfg(unix)]
286 save_in_directory(&directory, &path, &store)?;
287 #[cfg(not(unix))]
288 save_at(&path, &store)?;
289 Ok(result)
290 }
291
292 fn validate(&self) -> Result<(), String> {
293 if self.graphs.len() > MAX_GRAPHS {
294 return Err(format!("work graph store exceeds {MAX_GRAPHS} graphs"));
295 }
296 for (graph_id, graph) in &self.graphs {
297 validate_id(graph_id, "graph_id")?;
298 if graph.graph_id() != graph_id {
299 return Err("work graph key/id mismatch".to_string());
300 }
301 graph
302 .validate_invariants()
303 .map_err(|error| error.to_string())?;
304 }
305 Ok(())
306 }
307}
308
309pub fn validate_id(value: &str, field: &str) -> Result<(), String> {
310 if value.is_empty()
311 || value.len() > 128
312 || !value
313 .bytes()
314 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
315 {
316 return Err(format!(
317 "invalid {field}: expected 1..=128 ASCII [A-Za-z0-9._-]"
318 ));
319 }
320 Ok(())
321}
322
323fn store_path(project_root: &str) -> Result<PathBuf, String> {
324 let hash = crate::core::project_hash::hash_project_root(project_root);
325 Ok(crate::core::paths::state_dir()?
326 .join("work-graphs")
327 .join(format!("{hash}.json")))
328}
329
330fn load_at(path: &Path, project_root: &str) -> Result<WorkGraphStore, String> {
331 let bytes = match read_bounded_regular_nofollow(path) {
332 Ok(bytes) => bytes,
333 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
334 return Ok(WorkGraphStore::empty(project_root));
335 }
336 Err(error) => return Err(error.to_string()),
337 };
338 let mut store: WorkGraphStore =
339 serde_json::from_slice(&bytes).map_err(|error| error.to_string())?;
340 let expected = crate::core::project_hash::hash_project_root(project_root);
341 if store.schema_version != STORE_SCHEMA_VERSION || store.project_root_hash != expected {
342 return Err("work graph store scope or schema mismatch".to_string());
343 }
344 for (graph_id, graph) in &mut store.graphs {
345 graph
346 .bind_graph_id(graph_id)
347 .map_err(|error| error.to_string())?;
348 }
349 store.validate()?;
350 Ok(store)
351}
352
353#[cfg(unix)]
354fn load_in_directory(
355 directory: &fs::File,
356 path: &Path,
357 project_root: &str,
358) -> Result<WorkGraphStore, String> {
359 let leaf = path
360 .file_name()
361 .ok_or_else(|| "work graph store has no filename".to_string())?;
362 let bytes = match read_bounded_regular_from_directory(directory, leaf) {
363 Ok(bytes) => bytes,
364 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
365 return Ok(WorkGraphStore::empty(project_root));
366 }
367 Err(error) => return Err(error.to_string()),
368 };
369 decode_store(&bytes, project_root)
370}
371
372#[cfg(unix)]
373fn decode_store(bytes: &[u8], project_root: &str) -> Result<WorkGraphStore, String> {
374 let mut store: WorkGraphStore =
375 serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
376 let expected = crate::core::project_hash::hash_project_root(project_root);
377 if store.schema_version != STORE_SCHEMA_VERSION || store.project_root_hash != expected {
378 return Err("work graph store scope or schema mismatch".to_string());
379 }
380 for (graph_id, graph) in &mut store.graphs {
381 graph
382 .bind_graph_id(graph_id)
383 .map_err(|error| error.to_string())?;
384 }
385 store.validate()?;
386 Ok(store)
387}
388
389#[cfg(unix)]
390fn read_bounded_regular_nofollow(path: &Path) -> std::io::Result<Vec<u8>> {
391 let leaf = path
392 .file_name()
393 .ok_or_else(|| std::io::Error::other("work graph store has no filename"))?;
394 let directory = open_parent_directory(path)?;
395 read_bounded_regular_from_directory(&directory, leaf)
396}
397
398#[cfg(unix)]
399fn open_parent_directory(path: &Path) -> std::io::Result<fs::File> {
400 use std::os::unix::fs::OpenOptionsExt;
401 let parent = path
402 .parent()
403 .ok_or_else(|| std::io::Error::other("work graph store has no parent"))?;
404 fs::OpenOptions::new()
405 .read(true)
406 .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY)
407 .open(parent)
408}
409
410#[cfg(unix)]
411fn read_bounded_regular_from_directory(
412 directory: &fs::File,
413 leaf: &std::ffi::OsStr,
414) -> std::io::Result<Vec<u8>> {
415 use std::ffi::CString;
416 use std::os::fd::{AsRawFd, FromRawFd};
417 use std::os::unix::ffi::OsStrExt;
418
419 let leaf = CString::new(leaf.as_bytes())
420 .map_err(|_| std::io::Error::other("work graph filename contains NUL"))?;
421 let fd = unsafe {
423 libc::openat(
424 directory.as_raw_fd(),
425 leaf.as_ptr(),
426 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK,
427 )
428 };
429 if fd < 0 {
430 return Err(std::io::Error::last_os_error());
431 }
432 let file = unsafe { fs::File::from_raw_fd(fd) };
434 read_opened_store(file)
435}
436
437#[cfg(not(unix))]
438fn read_bounded_regular_nofollow(path: &Path) -> std::io::Result<Vec<u8>> {
439 if let Ok(metadata) = fs::symlink_metadata(path) {
440 if crate::core::pathutil::is_symlink_or_reparse(&metadata) {
441 return Err(std::io::Error::other("refusing linked work graph store"));
442 }
443 }
444 read_opened_store(fs::OpenOptions::new().read(true).open(path)?)
445}
446
447fn read_opened_store(mut file: fs::File) -> std::io::Result<Vec<u8>> {
448 let before = file.metadata()?;
449 if !before.file_type().is_file() {
450 return Err(std::io::Error::other(
451 "work graph store must be a regular file",
452 ));
453 }
454 if before.len() > MAX_STORE_BYTES {
455 return Err(std::io::Error::other(format!(
456 "work graph store exceeds {MAX_STORE_BYTES} bytes"
457 )));
458 }
459 let mut bytes = Vec::with_capacity(before.len() as usize);
460 (&mut file)
461 .take(MAX_STORE_BYTES + 1)
462 .read_to_end(&mut bytes)?;
463 if bytes.len() as u64 > MAX_STORE_BYTES {
464 return Err(std::io::Error::other(format!(
465 "work graph store exceeds {MAX_STORE_BYTES} bytes"
466 )));
467 }
468 let after = file.metadata()?;
469 if before.len() != after.len()
470 || before
471 .modified()
472 .ok()
473 .zip(after.modified().ok())
474 .is_some_and(|(before, after)| before != after)
475 {
476 return Err(std::io::Error::other(
477 "work graph store changed during read",
478 ));
479 }
480 Ok(bytes)
481}
482
483#[cfg(not(unix))]
484fn save_at(path: &Path, store: &WorkGraphStore) -> Result<(), String> {
485 let bytes = serde_json::to_vec(store).map_err(|error| error.to_string())?;
486 if bytes.len() as u64 > MAX_STORE_BYTES {
487 return Err(format!("work graph store exceeds {MAX_STORE_BYTES} bytes"));
488 }
489 #[cfg(unix)]
490 {
491 secure_atomic_write_nofollow(path, &bytes).map_err(|error| error.to_string())
492 }
493 #[cfg(not(unix))]
494 {
495 crate::core::atomic_fs::try_atomic_write(path, &bytes, None)
496 .map_err(|error| error.to_string())
497 }
498}
499
500#[cfg(unix)]
501fn save_in_directory(
502 directory: &fs::File,
503 path: &Path,
504 store: &WorkGraphStore,
505) -> Result<(), String> {
506 let bytes = serde_json::to_vec(store).map_err(|error| error.to_string())?;
507 if bytes.len() as u64 > MAX_STORE_BYTES {
508 return Err(format!("work graph store exceeds {MAX_STORE_BYTES} bytes"));
509 }
510 let leaf = path
511 .file_name()
512 .ok_or_else(|| "work graph store has no filename".to_string())?;
513 secure_atomic_write_in_directory(directory, leaf, &bytes).map_err(|error| error.to_string())
514}
515
516#[cfg(unix)]
517fn secure_atomic_write_in_directory(
518 directory: &fs::File,
519 leaf: &std::ffi::OsStr,
520 bytes: &[u8],
521) -> std::io::Result<()> {
522 use std::ffi::CString;
523 use std::os::fd::{AsRawFd, FromRawFd};
524 use std::os::unix::ffi::OsStrExt;
525 use std::time::{SystemTime, UNIX_EPOCH};
526
527 let leaf = CString::new(leaf.as_bytes())
528 .map_err(|_| std::io::Error::other("work graph filename contains NUL"))?;
529 let nonce = SystemTime::now()
530 .duration_since(UNIX_EPOCH)
531 .map_or(0, |duration| duration.as_nanos());
532 let temp = CString::new(format!(".work-graph.tmp.{}.{}", std::process::id(), nonce))
533 .expect("generated work graph temp name contains no NUL");
534 let directory_fd = directory.as_raw_fd();
535 let temp_fd = unsafe {
537 libc::openat(
538 directory_fd,
539 temp.as_ptr(),
540 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
541 0o600,
542 )
543 };
544 if temp_fd < 0 {
545 return Err(std::io::Error::last_os_error());
546 }
547 let mut file = unsafe { fs::File::from_raw_fd(temp_fd) };
549 let write_result = (|| {
550 file.write_all(bytes)?;
551 file.sync_all()?;
552 if unsafe { libc::fchmod(file.as_raw_fd(), 0o600) } != 0 {
554 return Err(std::io::Error::last_os_error());
555 }
556 if unsafe { libc::renameat(directory_fd, temp.as_ptr(), directory_fd, leaf.as_ptr()) } != 0
558 {
559 return Err(std::io::Error::last_os_error());
560 }
561 directory.sync_all()
562 })();
563 if write_result.is_err() {
564 unsafe {
566 libc::unlinkat(directory_fd, temp.as_ptr(), 0);
567 }
568 }
569 write_result
570}
571
572fn reject_symlink(path: &Path) -> Result<(), String> {
573 let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?;
574 if metadata.file_type().is_symlink() {
575 return Err(format!(
576 "refusing symlink work graph path: {}",
577 path.display()
578 ));
579 }
580 Ok(())
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586 use crate::core::work_graph::WorkNodeBudget;
587
588 fn graph_with_claim(root: &str, child: &str, claim: &str) -> BoundedWorkGraph {
589 let mut graph = BoundedWorkGraph::default();
590 graph
591 .add_root(
592 root.into(),
593 format!("{root}-agent"),
594 format!("capsule:{root}"),
595 WorkNodeBudget {
596 tokens_allocated: 100,
597 tokens_consumed: 0,
598 cost_micros_allocated: 100,
599 cost_micros_consumed: 0,
600 },
601 )
602 .unwrap();
603 graph
604 .queue_child(
605 root,
606 child.into(),
607 format!("{child}-agent"),
608 format!("capsule:{child}"),
609 WorkNodeBudget {
610 tokens_allocated: 20,
611 tokens_consumed: 0,
612 cost_micros_allocated: 20,
613 cost_micros_consumed: 0,
614 },
615 )
616 .unwrap();
617 graph.set_path_claims(child, vec![claim.into()]).unwrap();
618 graph
619 }
620
621 #[test]
622 fn write_revision_detects_hidden_changes_and_rejects_stale_mutation() {
623 let isolated = crate::core::data_dir::isolated_data_dir();
624 let project = isolated.path().to_str().unwrap();
625 WorkGraphStore::mutate(project, |store| {
626 store.create("graph", graph_with_claim("root", "child", "path:src/old"))
627 })
628 .unwrap();
629 let before = WorkGraphStore::load(project).unwrap();
630 let revision = before.graph_revision("graph").unwrap();
631 let observation = before.graph("graph").unwrap().observation();
632 WorkGraphStore::mutate_graph_if_revision(project, "graph", &revision, |graph| {
633 graph
634 .set_path_claims("child", vec!["path:src/new".into()])
635 .map_err(|error| error.to_string())
636 })
637 .unwrap();
638 let after = WorkGraphStore::load(project).unwrap();
639 assert_eq!(observation, after.graph("graph").unwrap().observation());
640 assert_ne!(revision, after.graph_revision("graph").unwrap());
641 let mut invoked = false;
642 let result = WorkGraphStore::mutate_graph_if_revision(project, "graph", &revision, |_| {
643 invoked = true;
644 Ok(())
645 });
646 assert_eq!(result.unwrap_err(), "work graph revision conflict");
647 assert!(!invoked);
648 assert_eq!(
649 after.graph_revision("graph").unwrap(),
650 WorkGraphStore::load(project)
651 .unwrap()
652 .graph_revision("graph")
653 .unwrap()
654 );
655 }
656
657 #[test]
658 fn ids_are_bounded_and_path_safe() {
659 assert!(validate_id("graph-1.node", "graph_id").is_ok());
660 assert!(validate_id("../escape", "graph_id").is_err());
661 assert!(validate_id("", "graph_id").is_err());
662 assert!(validate_id(&"a".repeat(129), "graph_id").is_err());
663 }
664
665 #[test]
666 fn store_round_trip_preserves_project_scope_and_graph() {
667 let temp = tempfile::tempdir().expect("tempdir");
668 let path = temp.path().join("graph.json");
669 let mut store = WorkGraphStore::empty("/project/a");
670 let mut graph = BoundedWorkGraph::default();
671 graph
672 .add_root(
673 "root".to_string(),
674 "agent-1".to_string(),
675 "capsule:sha256:abc".to_string(),
676 WorkNodeBudget {
677 tokens_allocated: 100,
678 tokens_consumed: 0,
679 cost_micros_allocated: 50,
680 cost_micros_consumed: 0,
681 },
682 )
683 .expect("root");
684 store.create("graph-1", graph).expect("create");
685 #[cfg(unix)]
686 {
687 let directory = open_parent_directory(&path).expect("parent");
688 save_in_directory(&directory, &path, &store).expect("save");
689 }
690 #[cfg(not(unix))]
691 save_at(&path, &store).expect("save");
692
693 let loaded = load_at(&path, "/project/a").expect("load");
694 assert_eq!(loaded.graph("graph-1").expect("graph").total_count(), 1);
695 assert!(load_at(&path, "/project/b").is_err());
696 }
697
698 #[cfg(unix)]
699 #[test]
700 fn store_rejects_symlink_target() {
701 use std::os::unix::fs::symlink;
702
703 let temp = tempfile::tempdir().expect("tempdir");
704 let target = temp.path().join("target.json");
705 fs::write(&target, b"{}").expect("target");
706 let link = temp.path().join("graph.json");
707 symlink(&target, &link).expect("symlink");
708 assert!(load_at(&link, "/project/a").is_err());
709 }
710
711 #[cfg(unix)]
712 #[test]
713 fn store_rejects_symlink_parent_directory() {
714 use std::os::unix::fs::symlink;
715
716 let temp = tempfile::tempdir().expect("tempdir");
717 let target = temp.path().join("target");
718 fs::create_dir(&target).expect("target dir");
719 fs::write(target.join("graph.json"), b"{}").expect("target");
720 let link = temp.path().join("linked-parent");
721 symlink(&target, &link).expect("symlink");
722 assert!(load_at(&link.join("graph.json"), "/project/a").is_err());
723 }
724
725 #[test]
726 fn store_rejects_oversized_regular_file_before_allocation() {
727 let temp = tempfile::tempdir().expect("tempdir");
728 let path = temp.path().join("graph.json");
729 let file = fs::File::create(&path).expect("file");
730 file.set_len(MAX_STORE_BYTES + 1).expect("set length");
731 assert!(load_at(&path, "/project/a").is_err());
732 }
733
734 #[cfg(unix)]
735 #[test]
736 fn store_rejects_fifo_without_blocking() {
737 use std::ffi::CString;
738 use std::os::unix::ffi::OsStrExt;
739
740 let temp = tempfile::tempdir().expect("tempdir");
741 let path = temp.path().join("graph.fifo");
742 let raw = CString::new(path.as_os_str().as_bytes()).expect("path");
743 assert_eq!(unsafe { libc::mkfifo(raw.as_ptr(), 0o600) }, 0);
745 assert!(load_at(&path, "/project/a").is_err());
746 }
747
748 #[cfg(unix)]
749 #[test]
750 fn store_write_stays_with_held_parent_after_path_replacement() {
751 use std::os::unix::fs::{OpenOptionsExt, symlink};
752
753 let temp = tempfile::tempdir().expect("tempdir");
754 let parent = temp.path().join("store");
755 let relocated = temp.path().join("relocated");
756 let attacker = temp.path().join("attacker");
757 fs::create_dir(&parent).expect("parent");
758 fs::create_dir(&attacker).expect("attacker");
759 let directory = fs::OpenOptions::new()
760 .read(true)
761 .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY)
762 .open(&parent)
763 .expect("held parent");
764 let store = WorkGraphStore::empty("/project/a");
765 save_in_directory(&directory, &parent.join("graph.json"), &store).expect("initial store");
766 let loaded = load_in_directory(&directory, &parent.join("graph.json"), "/project/a")
767 .expect("load through held directory");
768 fs::rename(&parent, &relocated).expect("replace parent");
769 symlink(&attacker, &parent).expect("attacker symlink");
770
771 save_in_directory(&directory, &parent.join("graph.json"), &loaded)
772 .expect("write through held directory");
773
774 assert!(load_at(&relocated.join("graph.json"), "/project/a").is_ok());
775 assert!(!attacker.join("graph.json").exists());
776 }
777
778 #[test]
779 fn store_serializes_overlapping_path_claims_across_graphs() {
780 let mut store = WorkGraphStore::empty("/project/a");
781 store
782 .create(
783 "g1",
784 graph_with_claim("root1", "child1", "path:project/src"),
785 )
786 .unwrap();
787 store
788 .create(
789 "g2",
790 graph_with_claim("root2", "child2", "path:project/src/lib.rs"),
791 )
792 .unwrap();
793
794 store.claim_node_execution("g1", "child1", 10, 100).unwrap();
795 assert!(store.claim_node_execution("g2", "child2", 10, 100).is_err());
796 assert!(matches!(
797 store
798 .claim_node_execution("g1", "child1", 100, 200)
799 .unwrap(),
800 ClaimNodeExecution::RecoveredExpired
801 ));
802 assert_eq!(
803 store
804 .graph("g1")
805 .unwrap()
806 .get_node("child1")
807 .unwrap()
808 .status,
809 crate::core::work_graph::NodeStatus::Stopped
810 );
811 let bytes = serde_json::to_vec(&store).unwrap();
814 let mut store: WorkGraphStore = serde_json::from_slice(&bytes).unwrap();
815 store.validate().unwrap();
816 assert!(store.claim_node_execution("g2", "child2", 100, 200).is_ok());
817 assert_eq!(
818 store
819 .graph("g2")
820 .unwrap()
821 .get_node("child2")
822 .unwrap()
823 .status,
824 crate::core::work_graph::NodeStatus::Active
825 );
826 }
827}