| 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 |