1use std::path::Path;
14
15use kernel::{Address, AxCode, AxError, Locator};
16use storage::Cas;
17
18pub const REST_DIR: &str = ".rest";
20
21pub struct OffloadSite<'a> {
28 pub cas: &'a mut Cas,
29 pub city_root: &'a Path,
30 pub room: &'a Address,
31 pub origin: storage::BlockOrigin,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct OffloadRecord {
38 pub substitute: Vec<u8>,
39 pub original: Locator,
40 pub rest_path: String,
41 pub original_len: u64,
42}
43
44fn hint_line(total: u64, rest_path: &str, locator: &Locator) -> String {
45 format!("\n[offloaded: total {total} bytes; rest at {rest_path}; original {locator}]")
46}
47
48fn materialized(
53 bytes: &[u8],
54 site: &OffloadSite<'_>,
55 locator: &Locator,
56) -> Result<String, AxError> {
57 let name = rest_file_name(locator);
58 let room = site.room.as_str();
59 materialize(bytes, &site.city_root.join(room).join(REST_DIR), &name)?;
60 Ok(format!("{room}/{REST_DIR}/{name}"))
61}
62
63fn rest_file_name(locator: &Locator) -> String {
64 let mut tail: String = locator
65 .to_string()
66 .chars()
67 .rev()
68 .take(16)
69 .collect::<String>()
70 .chars()
71 .rev()
72 .collect();
73 tail.retain(|c| c.is_ascii_alphanumeric());
74 format!("rest-{tail}.dat")
75}
76
77fn materialize(bytes: &[u8], dir: &Path, name: &str) -> Result<(), AxError> {
78 let io = |err: std::io::Error| {
79 AxError::failure(
80 AxCode::StorageFatal,
81 "materialize rest file",
82 err.to_string(),
83 )
84 .with_recovery(
85 "free space on the disk holding the city, or fix the permissions of the \
86 run's environment directory, then ask for the result again",
87 )
88 };
89 let path = dir.join(name);
90 if path.exists() {
91 return Ok(());
92 }
93 std::fs::create_dir_all(dir).map_err(io)?;
94 std::fs::write(&path, bytes).map_err(io)?;
95 let mut perms = std::fs::metadata(&path).map_err(io)?.permissions();
96 perms.set_readonly(true);
97 std::fs::set_permissions(&path, perms).map_err(io)?;
98 Ok(())
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
104pub(crate) struct Tee {
105 pub(crate) original: Locator,
106 pub(crate) rest_path: String,
107}
108
109pub(crate) fn tee(bytes: &[u8], site: &mut OffloadSite<'_>) -> Result<Tee, AxError> {
113 let hash = site
114 .cas
115 .put_for(bytes, &site.origin)
116 .map_err(storage::StorageError::into_ax)?;
117 let original = Locator::cas(hash);
118 let rest_path = materialized(bytes, site, &original)?;
119 Ok(Tee {
120 original,
121 rest_path,
122 })
123}
124
125pub fn offload(
129 bytes: &[u8],
130 cap_bytes: u64,
131 site: &mut OffloadSite<'_>,
132) -> Result<OffloadRecord, AxError> {
133 let original_len = u64::try_from(bytes.len()).map_err(|_| {
134 AxError::failure(AxCode::InvalidArgs, "offload result", "length exceeds u64").with_recovery(
135 "ask the tool for less at a time: this result is larger than a byte \
136 count this city can hold",
137 )
138 })?;
139 if original_len <= cap_bytes {
140 return Err(AxError::failure(
141 AxCode::InvalidArgs,
142 "offload result",
143 "lossless input: only lossy transforms store (invariant 3)",
144 )
145 .with_recovery(
146 "pass this result through unchanged; it already fits the cap, and offload \
147 stores only what it has to cut",
148 ));
149 }
150 let Tee {
153 original,
154 rest_path,
155 } = tee(bytes, site)?;
156 let hint = hint_line(original_len, &rest_path, &original);
157 let hint_len = u64::try_from(hint.len()).map_err(|_| {
158 AxError::failure(AxCode::InvalidArgs, "offload result", "hint exceeds u64").with_recovery(
159 "report this against runtime::offload: the hint line names one path \
160 and two byte counts and cannot reach this length",
161 )
162 })?;
163 let head_budget = cap_bytes.checked_sub(hint_len).ok_or_else(|| {
164 AxError::failure(
165 AxCode::InvalidArgs,
166 "offload result",
167 format!("cap {cap_bytes} cannot even hold the {hint_len}-byte hint"),
168 )
169 .with_recovery("raise the cap or skip offload for this result")
170 })?;
171 let head_len = usize::try_from(head_budget.min(original_len)).map_err(|_| {
172 AxError::failure(AxCode::InvalidArgs, "offload result", "head exceeds usize").with_recovery(
173 "lower `[tool] result_cap_bytes`: the kept head would be longer than \
174 this machine can address",
175 )
176 })?;
177 let mut substitute = bytes.get(..head_len).unwrap_or_default().to_vec();
178 substitute.extend_from_slice(hint.as_bytes());
179 let substitute_len = u64::try_from(substitute.len()).map_err(|_| {
181 AxError::failure(
182 AxCode::InvalidArgs,
183 "offload result",
184 "substitute exceeds u64",
185 )
186 .with_recovery(
187 "lower `[tool] result_cap_bytes`: the shortened result is longer than a \
188 byte count this city can hold",
189 )
190 })?;
191 if substitute_len > cap_bytes || substitute_len > original_len {
192 return Err(AxError::failure(
193 AxCode::InvalidArgs,
194 "offload result",
195 "substitute outgrew its bounds (invariant 2)",
196 )
197 .with_recovery(
198 "report this against runtime::offload: the shortened result must stay \
199 within both the cap and the original length, and it did not",
200 ));
201 }
202 Ok(OffloadRecord {
203 substitute,
204 original,
205 rest_path,
206 original_len,
207 })
208}
209
210pub fn rematerialize(locator: &Locator, site: &mut OffloadSite<'_>) -> Result<String, AxError> {
213 let Locator::Cas { hash, .. } = locator else {
214 return Err(AxError::failure(
215 AxCode::InvalidArgs,
216 "rematerialize rest file",
217 "locator is not cas-addressed",
218 )
219 .with_recovery(
220 "pass the `cas:` locator the offload record carries under `original`; a \
221 `file:` locator names a path that cleanup already removed",
222 ));
223 };
224 let bytes = site.cas.get(hash).map_err(storage::StorageError::into_ax)?;
225 materialized(&bytes, site, locator)
226}
227
228#[cfg(test)]
229#[allow(
230 clippy::unwrap_used,
231 clippy::expect_used,
232 clippy::panic,
233 clippy::indexing_slicing,
234 reason = "test code"
235)]
236pub(crate) mod tests {
237 use super::*;
238
239 pub(crate) fn origin() -> storage::BlockOrigin {
240 storage::BlockOrigin {
241 run: kernel::RunId::from_bytes([7; 16]),
242 building: kernel::Address::parse("lab").unwrap(),
243 }
244 }
245
246 fn site(dir: &tempfile::TempDir) -> (Cas, Address) {
247 let cas = Cas::open(&dir.path().join("cas")).unwrap();
248 (cas, Address::parse("room").unwrap())
249 }
250
251 #[test]
252 fn a7_roundtrip_all_four_assertions() {
253 let dir = tempfile::tempdir().unwrap();
254 let (mut cas, room) = site(&dir);
255 let mut s = OffloadSite {
256 cas: &mut cas,
257 city_root: dir.path(),
258 room: &room,
259 origin: origin(),
260 };
261 let original: Vec<u8> = (0..40_000u32)
262 .map(|i| u8::try_from(i % 251).unwrap())
263 .collect();
264 let record = offload(&original, 4_096, &mut s).unwrap();
265 assert!(record.substitute.len() <= 4_096);
267 assert!(record.substitute.len() < original.len());
268 assert!(
269 String::from_utf8_lossy(&record.substitute).contains("[offloaded: total 40000 bytes")
270 );
271 assert!(
272 String::from_utf8_lossy(&record.substitute).contains("; rest at room/.rest/rest-"),
273 "the hint names the rest file by its address in the room"
274 );
275 assert_eq!(
277 std::fs::read(dir.path().join(&record.rest_path)).unwrap(),
278 original
279 );
280 let Locator::Cas { hash, .. } = &record.original else {
282 panic!("expected a cas locator");
283 };
284 assert_eq!(s.cas.get(hash).unwrap(), original);
285 let mut perms = std::fs::metadata(dir.path().join(&record.rest_path))
287 .unwrap()
288 .permissions();
289 #[allow(clippy::permissions_set_readonly_false, reason = "test cleanup")]
290 perms.set_readonly(false);
291 std::fs::set_permissions(dir.path().join(&record.rest_path), perms).unwrap();
292 std::fs::remove_file(dir.path().join(&record.rest_path)).unwrap();
293 let back = rematerialize(&record.original, &mut s).unwrap();
294 assert_eq!(std::fs::read(dir.path().join(&back)).unwrap(), original);
295 }
296
297 #[test]
298 fn the_rest_address_is_one_the_read_tool_admits_and_resolves() {
299 let dir = tempfile::tempdir().unwrap();
300 let (mut cas, room) = site(&dir);
301 let mut s = OffloadSite {
302 cas: &mut cas,
303 city_root: dir.path(),
304 room: &room,
305 origin: origin(),
306 };
307 let original = vec![3u8; 30_000];
308 let record = offload(&original, 2_048, &mut s).unwrap();
309 let admitted =
310 crate::tools::admit(&record.rest_path, "read", &|_| kernel::ReadVerdict::Open).unwrap();
311 assert_eq!(
312 std::fs::read(dir.path().join(admitted.as_str())).unwrap(),
313 original,
314 "the read tool resolves the rest address from the city root"
315 );
316 }
317
318 #[test]
319 fn the_original_is_pinned_for_the_run_and_building_it_was_cut_for() {
320 let dir = tempfile::tempdir().unwrap();
321 let (mut cas, room) = site(&dir);
322 let mut s = OffloadSite {
323 cas: &mut cas,
324 city_root: dir.path(),
325 room: &room,
326 origin: origin(),
327 };
328 let record = offload(&vec![3u8; 30_000], 2_048, &mut s).unwrap();
329 let Locator::Cas { hash, .. } = &record.original else {
330 panic!("expected a cas locator");
331 };
332 assert_eq!(s.cas.origins(hash).unwrap(), vec![origin()]);
333 }
334
335 #[test]
336 fn same_bytes_offload_to_the_same_locator() {
337 let dir = tempfile::tempdir().unwrap();
338 let (mut cas, room) = site(&dir);
339 let mut s = OffloadSite {
340 cas: &mut cas,
341 city_root: dir.path(),
342 room: &room,
343 origin: origin(),
344 };
345 let original = vec![7u8; 30_000];
346 let one = offload(&original, 2_048, &mut s).unwrap();
347 let two = offload(&original, 2_048, &mut s).unwrap();
348 assert_eq!(
349 one.original, two.original,
350 "CAS dedup makes offload idempotent"
351 );
352 assert_eq!(one.rest_path, two.rest_path);
353 }
354
355 #[test]
356 fn lossless_input_and_hopeless_caps_are_refused() {
357 let dir = tempfile::tempdir().unwrap();
358 let (mut cas, room) = site(&dir);
359 let mut s = OffloadSite {
360 cas: &mut cas,
361 city_root: dir.path(),
362 room: &room,
363 origin: origin(),
364 };
365 let err = offload(b"small", 4_096, &mut s).unwrap_err();
366 assert_eq!(*err.code(), AxCode::InvalidArgs);
367 let big = vec![1u8; 20_000];
368 let err = offload(&big, 16, &mut s).unwrap_err();
369 assert!(err.subject().contains("hint"));
370 }
371}