返回 CodeWhale
artifacts.rs
根目录 / crates / tui / src / fleet / artifacts.rs
1 //! Fleet artifact I/O. Both verifier publication and HTTP evidence reads use
2 //! the same workspace-relative, opened-directory authority. This replaces the
3 //! ordinary path-based writes in task_spec and reads in runtime_api.
4
5 #[cfg(test)]
6 use std::fs::File;
7 use std::io::{self, Read};
8 use std::path::Path;
9
10 use super::files::WorkspaceFile;
11 pub(crate) use super::files::path_is_confined;
12
13 use anyhow::{Context, Result, ensure};
14 use codewhale_protocol::fleet::FleetArtifactRef;
15 use sha2::{Digest, Sha256};
16
17 // The HTTP preview stays small; verifying a larger artifact streams its digest
18 // without retaining all bytes. The writer shares the ceiling so it cannot
19 // publish an artifact the evidence reader is unable to verify.
20 const MAX_ARTIFACT_BYTES: u64 = 16 * 1024 * 1024;
21
22 pub(crate) fn write(workspace: &Path, relative: &Path, bytes: &[u8]) -> Result<()> {
23 ensure!(
24 bytes.len() as u64 <= MAX_ARTIFACT_BYTES,
25 "Fleet artifact exceeds the 16 MiB limit"
26 );
27 let parent = WorkspaceFile::open(workspace, relative, true)?;
28 match parent.publish(bytes) {
29 Ok(()) => Ok(()),
30 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
31 let existing = parent.open_file()?;
32 let mut saved = Vec::new();
33 existing
34 .take(bytes.len() as u64 + 1)
35 .read_to_end(&mut saved)?;
36 ensure!(
37 saved == bytes,
38 "An existing Fleet artifact contains different bytes"
39 );
40 Ok(())
41 }
42 Err(error) => Err(error).context("Publishing Fleet artifact"),
43 }
44 }
45
46 pub(crate) fn read_verified(
47 workspace: &Path,
48 artifact: &FleetArtifactRef,
49 preview_limit: u64,
50 ) -> Result<(Vec<u8>, u64)> {
51 let parent = WorkspaceFile::open(workspace, &artifact.path, false)?;
52 let file = parent.open_file()?;
53 let size = file.metadata()?.len();
54 ensure!(
55 size <= MAX_ARTIFACT_BYTES,
56 "Fleet artifact exceeds the 16 MiB verification limit"
57 );
58 ensure!(
59 artifact.size_bytes.is_none_or(|expected| expected == size),
60 "Fleet artifact size changed"
61 );
62 let checksum = artifact
63 .checksum
64 .as_deref()
65 .context("Fleet artifact has no recorded checksum")?;
66 let mut hasher = Sha256::new();
67 let mut preview = Vec::new();
68 let mut buffer = [0_u8; 8192];
69 let mut total = 0_u64;
70 // The digest and returned preview consume exactly the same bytes from the
71 // same opened file. A changed/replaced pathname is never reopened for data.
72 let mut reader = (&file).take(size + 1);
73 loop {
74 let count = reader.read(&mut buffer)?;
75 if count == 0 {
76 break;
77 }
78 total += count as u64;
79 ensure!(total <= size, "Fleet artifact grew while being read");
80 hasher.update(&buffer[..count]);
81 let remaining = preview_limit.saturating_sub(preview.len() as u64) as usize;
82 preview.extend_from_slice(&buffer[..count.min(remaining)]);
83 }
84 ensure!(
85 total == size && file.metadata()?.len() == size,
86 "Fleet artifact size changed while being read"
87 );
88 ensure!(
89 format!("sha256:{}", crate::hashing::hex_bytes(hasher.finalize())) == checksum,
90 "Fleet artifact checksum does not match the recorded receipt"
91 );
92 Ok((preview, size))
93 }
94
95 #[cfg(test)]
96 mod tests {
97 use super::*;
98 use codewhale_protocol::fleet::FleetArtifactKind;
99
100 fn reference(path: &str, bytes: &[u8]) -> FleetArtifactRef {
101 FleetArtifactRef {
102 kind: FleetArtifactKind::Receipt,
103 path: path.into(),
104 checksum: Some(format!("sha256:{}", crate::hashing::sha256_hex(bytes))),
105 mime_type: None,
106 size_bytes: Some(bytes.len() as u64),
107 }
108 }
109
110 #[test]
111 fn publication_is_immutable_and_verifies_beyond_the_preview() {
112 let workspace = tempfile::tempdir().unwrap();
113 let bytes = vec![b'a'; 128 * 1024];
114 let artifact = reference(".codewhale/fleet/receipt.json", &bytes);
115 write(workspace.path(), &artifact.path, &bytes).unwrap();
116 write(workspace.path(), &artifact.path, &bytes).unwrap();
117 assert!(write(workspace.path(), &artifact.path, b"replacement").is_err());
118 let (preview, size) = read_verified(workspace.path(), &artifact, 65_536).unwrap();
119 assert_eq!(preview, bytes[..65_536]);
120 assert_eq!(size, bytes.len() as u64);
121
122 // Changing bytes outside the returned preview must still fail the
123 // complete digest check, even when size/metadata remain unchanged.
124 let mut changed = bytes;
125 *changed.last_mut().unwrap() = b'b';
126 std::fs::write(workspace.path().join(&artifact.path), changed).unwrap();
127 let error = read_verified(workspace.path(), &artifact, 65_536).unwrap_err();
128 assert!(error.to_string().contains("checksum"));
129 }
130
131 #[test]
132 fn missing_digest_size_mismatch_and_oversized_files_fail_closed() {
133 let workspace = tempfile::tempdir().unwrap();
134 let mut artifact = reference("receipt.json", b"receipt");
135 write(workspace.path(), &artifact.path, b"receipt").unwrap();
136 artifact.checksum = None;
137 assert!(read_verified(workspace.path(), &artifact, 64).is_err());
138 artifact = reference("receipt.json", b"receipt");
139 artifact.size_bytes = Some(999);
140 assert!(read_verified(workspace.path(), &artifact, 64).is_err());
141 File::options()
142 .write(true)
143 .open(workspace.path().join(&artifact.path))
144 .unwrap()
145 .set_len(MAX_ARTIFACT_BYTES + 1)
146 .unwrap();
147 assert!(
148 read_verified(workspace.path(), &artifact, 64)
149 .unwrap_err()
150 .to_string()
151 .contains("limit")
152 );
153 assert!(
154 write(
155 workspace.path(),
156 Path::new("huge.json"),
157 &vec![0; MAX_ARTIFACT_BYTES as usize + 1]
158 )
159 .is_err()
160 );
161 assert!(!workspace.path().join("huge.json").exists());
162 }
163
164 #[cfg(unix)]
165 #[test]
166 fn parent_and_final_symlinks_and_hard_links_never_escape() {
167 use std::os::unix::fs::symlink;
168 let workspace = tempfile::tempdir().unwrap();
169 let outside = tempfile::tempdir().unwrap();
170 let secret = b"OUTSIDE_SYNTHETIC_RECEIPT_CANARY";
171 let outside_file = outside.path().join("private.txt");
172 std::fs::write(&outside_file, secret).unwrap();
173 std::fs::create_dir_all(workspace.path().join(".codewhale/fleet")).unwrap();
174
175 let final_link = reference(".codewhale/fleet/final.json", secret);
176 symlink(&outside_file, workspace.path().join(&final_link.path)).unwrap();
177 assert!(read_verified(workspace.path(), &final_link, 64).is_err());
178 assert!(write(workspace.path(), &final_link.path, b"overwrite").is_err());
179
180 symlink(
181 outside.path(),
182 workspace.path().join(".codewhale/fleet/parent"),
183 )
184 .unwrap();
185 let parent_link = reference(".codewhale/fleet/parent/private.txt", secret);
186 assert!(read_verified(workspace.path(), &parent_link, 64).is_err());
187 assert!(write(workspace.path(), &parent_link.path, b"overwrite").is_err());
188 assert!(
189 write(
190 workspace.path(),
191 Path::new(".codewhale/fleet/parent/new.json"),
192 b"new"
193 )
194 .is_err()
195 );
196 assert!(!outside.path().join("new.json").exists());
197
198 let hard_link = reference(".codewhale/fleet/hard.json", secret);
199 std::fs::hard_link(&outside_file, workspace.path().join(&hard_link.path)).unwrap();
200 assert!(read_verified(workspace.path(), &hard_link, 64).is_err());
201 assert!(write(workspace.path(), &hard_link.path, b"overwrite").is_err());
202 assert_eq!(std::fs::read(outside_file).unwrap(), secret);
203 }
204
205 #[cfg(unix)]
206 #[test]
207 fn publication_uses_the_open_parent_after_a_path_swap() {
208 use std::os::unix::fs::symlink;
209 let workspace = tempfile::tempdir().unwrap();
210 let outside = tempfile::tempdir().unwrap();
211 let parent =
212 WorkspaceFile::open(workspace.path(), Path::new("receipts/item.json"), true).unwrap();
213 std::fs::rename(
214 workspace.path().join("receipts"),
215 workspace.path().join("pinned"),
216 )
217 .unwrap();
218 symlink(outside.path(), workspace.path().join("receipts")).unwrap();
219 parent.publish(b"complete receipt").unwrap();
220 assert_eq!(
221 std::fs::read(workspace.path().join("pinned/item.json")).unwrap(),
222 b"complete receipt"
223 );
224 assert!(!outside.path().join("item.json").exists());
225 assert!(write(workspace.path(), Path::new("receipts/next.json"), b"next").is_err());
226 }
227 }
228
228 lines RUST