Skip to main content

agent_sdk_store_file/
content.rs

1use std::path::PathBuf;
2
3use agent_sdk_core::{
4    ContentResolutionError, ContentResolutionErrorKind, ContentResolutionPolicy,
5    ContentResolveRequest, ContentResolver, ContentStore, ResolvedContent,
6    content::{ContentRef, ResolvedContent as CoreResolvedContent},
7};
8
9use crate::util::{
10    content_error, read_bytes, read_json, root_join, safe_segment, sha256_hex, write_bytes,
11    write_json,
12};
13
14#[derive(Clone, Debug)]
15/// Filesystem-backed content resolver and store.
16pub struct FileContentStore {
17    root: PathBuf,
18}
19
20#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
21struct ContentMetadata {
22    content_ref: ContentRef,
23    byte_len: u64,
24}
25
26impl FileContentStore {
27    /// Creates a content store rooted under the provided directory.
28    pub fn new(root: impl Into<PathBuf>) -> Self {
29        Self { root: root.into() }
30    }
31
32    fn content_dir(&self, content_ref: &ContentRef) -> PathBuf {
33        root_join(
34            &self.root,
35            &[
36                "content".to_string(),
37                safe_segment(content_ref.content_id.as_str()),
38            ],
39        )
40    }
41
42    fn metadata_path(&self, content_ref: &ContentRef) -> PathBuf {
43        self.content_dir(content_ref).join("metadata.json")
44    }
45
46    fn bytes_path(&self, content_ref: &ContentRef) -> PathBuf {
47        self.content_dir(content_ref).join("content.bin")
48    }
49}
50
51impl ContentResolver for FileContentStore {
52    fn resolve(
53        &self,
54        request: ContentResolveRequest,
55        policy: ContentResolutionPolicy,
56    ) -> Result<ResolvedContent, ContentResolutionError> {
57        if request.requested_version != request.content_ref.version {
58            return Err(content_error(
59                ContentResolutionErrorKind::VersionMismatch,
60                request.content_ref,
61                policy.policy_refs,
62            ));
63        }
64        let metadata = read_json::<ContentMetadata>(&self.metadata_path(&request.content_ref))
65            .map_err(|_| {
66                content_error(
67                    ContentResolutionErrorKind::StorageUnavailable,
68                    request.content_ref.clone(),
69                    policy.policy_refs.clone(),
70                )
71            })?
72            .ok_or_else(|| {
73                content_error(
74                    ContentResolutionErrorKind::Missing,
75                    request.content_ref.clone(),
76                    policy.policy_refs.clone(),
77                )
78            })?;
79        if !policy.allow_raw_content {
80            return Ok(CoreResolvedContent::redacted(
81                metadata.content_ref,
82                policy.policy_refs,
83            ));
84        }
85        let bytes = read_bytes(&self.bytes_path(&request.content_ref)).map_err(|_| {
86            content_error(
87                ContentResolutionErrorKind::StorageUnavailable,
88                request.content_ref.clone(),
89                policy.policy_refs.clone(),
90            )
91        })?;
92        if bytes.len() as u64 > policy.max_bytes {
93            return Err(content_error(
94                ContentResolutionErrorKind::MaxBytesExceeded,
95                request.content_ref,
96                policy.policy_refs,
97            ));
98        }
99        if policy.require_hash_match {
100            if let Some(expected) = &metadata.content_ref.content_hash {
101                let actual = format!("sha256:{}", sha256_hex(&bytes));
102                if expected != &actual {
103                    return Err(content_error(
104                        ContentResolutionErrorKind::HashMismatch,
105                        request.content_ref,
106                        policy.policy_refs,
107                    ));
108                }
109            }
110        }
111        Ok(ResolvedContent {
112            mime: metadata.content_ref.mime.clone(),
113            redacted_summary: metadata.content_ref.redacted_summary.clone(),
114            content_ref: metadata.content_ref,
115            bytes: Some(bytes),
116            policy_refs: policy.policy_refs,
117            raw_content_included: true,
118        })
119    }
120
121    fn store_resolved_content(
122        &self,
123        content_ref: &ContentRef,
124        bytes: Vec<u8>,
125    ) -> Result<(), ContentResolutionError> {
126        self.put_content(content_ref, bytes)
127    }
128}
129
130impl ContentStore for FileContentStore {
131    fn put_content(
132        &self,
133        content_ref: &ContentRef,
134        bytes: Vec<u8>,
135    ) -> Result<(), ContentResolutionError> {
136        let metadata = ContentMetadata {
137            content_ref: content_ref.clone(),
138            byte_len: bytes.len() as u64,
139        };
140        write_bytes(&self.bytes_path(content_ref), &bytes).map_err(|_| {
141            content_error(
142                ContentResolutionErrorKind::StorageUnavailable,
143                content_ref.clone(),
144                Vec::new(),
145            )
146        })?;
147        write_json(&self.metadata_path(content_ref), &metadata).map_err(|_| {
148            content_error(
149                ContentResolutionErrorKind::StorageUnavailable,
150                content_ref.clone(),
151                Vec::new(),
152            )
153        })
154    }
155}