1use crate::{LakeError, ScratchAttempt, ScratchAttemptId};
4use ahri_tre_types::{
5 AdmittedDatafileInput, AssetId, DataFileRecord, GovernedDatafileBinding, StudyId,
6};
7use sha2::{Digest, Sha256};
8use std::{
9 fs::File,
10 io::{Read, Write},
11 path::{Path, PathBuf},
12 process::{Command, Stdio},
13};
14
15fn physical_path(root: &str, datafile: &DataFileRecord) -> Result<PathBuf, LakeError> {
16 let root = std::fs::canonicalize(root)?;
17 let path = std::fs::canonicalize(crate::file_staging::datafile_storage_path(
18 root.to_str().ok_or(LakeError::QueryNotAdmissible)?,
19 &datafile.storage_uri,
20 )?)?;
21 if !path.starts_with(&root) || !path.is_file() {
22 return Err(LakeError::QueryNotAdmissible);
23 }
24 Ok(path)
25}
26
27pub fn attest_datafile(
30 root: &str,
31 study: StudyId,
32 asset: AssetId,
33 datafile: &DataFileRecord,
34) -> Result<GovernedDatafileBinding, LakeError> {
35 let path = physical_path(root, datafile)?;
36 let encoded = serde_json::to_vec(datafile).map_err(|_| LakeError::QueryNotAdmissible)?;
37 if encoded.len() > 16 * 1024 {
38 return Err(LakeError::QueryNotAdmissible);
39 }
40 Ok(GovernedDatafileBinding {
41 version_id: datafile.datafile_id.0,
42 asset_id: asset.0,
43 study_id: study.0,
44 storage_identity: crate::file_staging::hex_digest(Sha256::digest(
45 path.as_os_str().as_encoded_bytes(),
46 )),
47 descriptor_fingerprint: crate::file_staging::hex_digest(Sha256::digest(encoded)),
48 })
49}
50
51#[derive(serde::Serialize, serde::Deserialize)]
52struct Capture {
53 representation: (bool, bool),
54 root: String,
55 input: AdmittedDatafileInput,
56 directory: PathBuf,
57 deadline: chrono::DateTime<chrono::Utc>,
58 max_decoded: u64,
59}
60
61pub struct PreparedDisclosureDatafile {
62 scratch: ScratchAttempt,
63}
64impl PreparedDisclosureDatafile {
65 pub fn capture(
66 root: &str,
67 input: AdmittedDatafileInput,
68 scratch: &ScratchAttempt,
69 deadline: chrono::DateTime<chrono::Utc>,
70 max_decoded: u64,
71 executable: &Path,
72 ) -> Result<Self, LakeError> {
73 Self::capture_representation(
74 root,
75 input,
76 scratch,
77 deadline,
78 max_decoded,
79 executable,
80 (true, true),
81 )
82 }
83 pub fn capture_representation(
84 root: &str,
85 input: AdmittedDatafileInput,
86 scratch: &ScratchAttempt,
87 deadline: chrono::DateTime<chrono::Utc>,
88 max_decoded: u64,
89 executable: &Path,
90 representation: (bool, bool),
91 ) -> Result<Self, LakeError> {
92 if input.datafile.encrypted.unwrap_or(false) && !representation.0 && representation.1 {
93 return Err(LakeError::QueryNotAdmissible);
94 }
95 if max_decoded == 0 || max_decoded > i64::MAX as u64 || deadline <= chrono::Utc::now() {
96 return Err(LakeError::QueryNotAdmissible);
97 }
98 let scratch = scratch
99 .create_child(
100 ScratchAttemptId::new(&uuid::Uuid::new_v4().simple().to_string())
101 .map_err(|_| LakeError::QueryNotAdmissible)?,
102 )
103 .map_err(|_| LakeError::QueryNotAdmissible)?;
104 let request = Capture {
105 representation,
106 root: root.into(),
107 input,
108 directory: scratch.path().to_owned(),
109 deadline,
110 max_decoded,
111 };
112 let encoded = serde_json::to_vec(&request).map_err(|_| LakeError::QueryNotAdmissible)?;
113 if encoded.len() > 16 * 1024 {
114 return Err(LakeError::QueryNotAdmissible);
115 }
116 let mut command = Command::new(executable);
117 crate::disclosure_inputs::parent_bound(&mut command)?;
118 let mut child = command
119 .env_clear()
120 .arg("--capture-datafile")
121 .stdin(Stdio::piped())
122 .stdout(Stdio::null())
123 .stderr(Stdio::null())
124 .spawn()?;
125 if child
126 .stdin
127 .take()
128 .ok_or(LakeError::QueryNotAdmissible)?
129 .write_all(&encoded)
130 .is_err()
131 {
132 let _ = child.kill();
133 let _ = child.wait();
134 return Err(LakeError::QueryNotAdmissible);
135 }
136 loop {
137 match child.try_wait() {
138 Ok(Some(status)) if status.success() && chrono::Utc::now() < deadline => break,
139 Ok(Some(_)) => return Err(LakeError::QueryNotAdmissible),
140 Ok(None) if chrono::Utc::now() < deadline => {
141 std::thread::sleep(std::time::Duration::from_millis(10))
142 }
143 _ => {
144 let _ = child.kill();
145 let _ = child.wait();
146 return Err(LakeError::QueryNotAdmissible);
147 }
148 }
149 }
150 Ok(Self { scratch })
151 }
152 pub fn open_representation(self, compress: bool) -> Result<Box<dyn Read + Send>, LakeError> {
153 let stream = self.open()?;
154 if compress {
155 Ok(Box::new(zstd::stream::read::Encoder::new(stream, 3)?))
156 } else {
157 Ok(Box::new(stream))
158 }
159 }
160 pub fn open(self) -> Result<DisclosureDatafileStream, LakeError> {
161 let file = File::open(self.scratch.path().join("payload"))?;
162 Ok(DisclosureDatafileStream { file, _input: self })
163 }
164}
165pub struct DisclosureDatafileStream {
166 file: File,
167 _input: PreparedDisclosureDatafile,
168}
169impl Read for DisclosureDatafileStream {
170 fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
171 self.file.read(buffer)
172 }
173}
174
175pub fn run_datafile_capture_worker() -> Result<(), LakeError> {
177 let mut encoded = Vec::new();
178 std::io::stdin()
179 .take(16 * 1024 + 1)
180 .read_to_end(&mut encoded)?;
181 if encoded.len() > 16 * 1024 {
182 return Err(LakeError::QueryNotAdmissible);
183 }
184 let request: Capture =
185 serde_json::from_slice(&encoded).map_err(|_| LakeError::QueryNotAdmissible)?;
186 let seconds = (request.deadline - chrono::Utc::now()).num_seconds();
187 if seconds < 1 || seconds > i64::from(u32::MAX) {
188 return Err(LakeError::QueryNotAdmissible);
189 }
190 unsafe {
192 libc::alarm(seconds as u32);
193 }
194 let expected = attest_datafile(
195 &request.root,
196 StudyId(request.input.binding.study_id),
197 AssetId(request.input.binding.asset_id),
198 &request.input.datafile,
199 )?;
200 if expected != request.input.binding {
201 return Err(LakeError::QueryNotAdmissible);
202 }
203 let source = physical_path(&request.root, &request.input.datafile)?;
204 let mut source = File::open(source)?;
205 let copied = request.directory.join("stored");
206 let mut target = std::fs::OpenOptions::new()
207 .create_new(true)
208 .write(true)
209 .open(&copied)?;
210 let workspace_bytes = request.max_decoded.min(1_000_000_000);
212 let mut hash = Sha256::new();
213 let mut buffer = [0u8; 64 * 1024];
214 let mut bytes = 0u64;
215 loop {
216 if chrono::Utc::now() >= request.deadline {
217 return Err(LakeError::QueryNotAdmissible);
218 }
219 let read = source.read(&mut buffer)?;
220 if read == 0 {
221 break;
222 }
223 bytes += read as u64;
224 if bytes > workspace_bytes.saturating_add(64 * 1024) {
226 return Err(LakeError::QueryNotAdmissible);
227 }
228 hash.update(&buffer[..read]);
229 target.write_all(&buffer[..read])?;
230 }
231 target.flush()?;
232 if crate::file_staging::hex_digest(hash.finalize()) != request.input.datafile.digest.as_str()
233 || request
234 .input
235 .datafile
236 .size_bytes
237 .is_some_and(|size| size != bytes)
238 {
239 return Err(LakeError::QueryNotAdmissible);
240 }
241 crate::file_staging::decode_datafile_bounded(
242 &copied,
243 &request.directory.join("payload"),
244 &request.input.datafile,
245 workspace_bytes,
246 request.deadline,
247 )?;
248 let payload = request.directory.join("payload");
249 if !request.representation.0 {
250 std::fs::rename(copied, payload)?;
251 } else if !request.representation.1 {
252 let source = if request.input.datafile.encrypted.unwrap_or(false) {
253 payload.with_extension("decrypted")
254 } else {
255 copied
256 };
257 std::fs::rename(source, payload)?;
258 }
259 Ok(())
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use std::os::unix::fs::PermissionsExt;
266
267 #[test]
268 fn capture_is_immutable_checks_attestation_and_bounds_decoded_content() {
269 let Ok(worker) = std::env::var("AHRI_QUERY_WORKER_TEST_BINARY") else {
270 return;
271 };
272 let id = uuid::Uuid::new_v4().simple().to_string();
273 let root = std::env::temp_dir().join(format!("datafile_capture_{id}"));
274 std::fs::create_dir(&root).unwrap();
275 let wrapper = root.join("worker");
276 let quote = |text: &str| format!("'{}'", text.replace('\'', "'\\''"));
277 std::fs::write(
278 &wrapper,
279 format!(
280 "#!/bin/sh\nexport LD_LIBRARY_PATH={}\nexec {} \"$@\"\n",
281 quote(&std::env::var("LD_LIBRARY_PATH").unwrap_or_default()),
282 quote(&worker)
283 ),
284 )
285 .unwrap();
286 std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o700)).unwrap();
287 let source = root.join("source.csv");
288 let content = b"participant,value\n1,secret\n";
289 std::fs::write(&source, content).unwrap();
290 let study = StudyId(uuid::Uuid::new_v4());
291 let asset = AssetId(uuid::Uuid::new_v4());
292 let staged = crate::DuckLakeAdapter::new(root.to_str().unwrap())
293 .stage_datafile(&crate::StageDataFileRequest {
294 source_path: source,
295 study_id: study,
296 asset_id: asset,
297 version_id: ahri_tre_types::VersionId(uuid::Uuid::new_v4()),
298 asset_name: ahri_tre_types::NcName::parse("content").unwrap(),
299 major: 1,
300 minor: 0,
301 patch: 0,
302 edam_format: "text/csv".into(),
303 compression: crate::DataFileCompression::Zstd,
304 encryption: crate::DataFileEncryption::Aes256Cbc,
305 })
306 .unwrap();
307 let binding =
308 attest_datafile(root.to_str().unwrap(), study, asset, &staged.datafile).unwrap();
309 let input = AdmittedDatafileInput {
310 binding,
311 datafile: staged.datafile.clone(),
312 };
313 let scratch_root = root.join("scratch");
314 std::fs::create_dir(&scratch_root).unwrap();
315 std::fs::set_permissions(&scratch_root, std::fs::Permissions::from_mode(0o700)).unwrap();
316 let scratch =
317 crate::TrustedScratch::open(&scratch_root, std::iter::empty::<&Path>()).unwrap();
318 let attempt = scratch
319 .create_attempt(ScratchAttemptId::new(&id).unwrap())
320 .unwrap();
321 let deadline = || chrono::Utc::now() + chrono::Duration::seconds(30);
322 assert!(
323 PreparedDisclosureDatafile::capture(
324 root.to_str().unwrap(),
325 input.clone(),
326 &attempt,
327 deadline(),
328 8,
329 &wrapper
330 )
331 .is_err()
332 );
333 let mut changed = input.clone();
334 changed.datafile.encryption_key = Some(vec![0; 32]);
335 assert!(
336 PreparedDisclosureDatafile::capture(
337 root.to_str().unwrap(),
338 changed,
339 &attempt,
340 deadline(),
341 1024,
342 &wrapper
343 )
344 .is_err()
345 );
346 let captured = PreparedDisclosureDatafile::capture(
347 root.to_str().unwrap(),
348 input.clone(),
349 &attempt,
350 deadline(),
351 1024,
352 &wrapper,
353 )
354 .unwrap();
355 std::fs::write(root.join(staged.storage_relative_path), b"replacement").unwrap();
356 let mut bytes = Vec::new();
357 captured.open().unwrap().read_to_end(&mut bytes).unwrap();
358 assert_eq!(bytes, content);
359 assert!(
360 PreparedDisclosureDatafile::capture(
361 root.to_str().unwrap(),
362 input,
363 &attempt,
364 deadline(),
365 1024,
366 &wrapper
367 )
368 .is_err()
369 );
370 attempt.cleanup().unwrap();
371 std::fs::remove_dir_all(root).unwrap();
372 }
373}