1use std::collections::{BTreeMap, BTreeSet};
4use std::fs::File;
5use std::io::{BufReader, Read, Seek, SeekFrom, Write};
6use std::path::{Component, Path, PathBuf};
7use std::sync::Arc;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::time::Duration;
10
11use aes_gcm::Aes256Gcm;
12use aes_gcm::aead::{Aead, KeyInit, Payload};
13use async_trait::async_trait;
14use rand::rngs::OsRng;
15use rsa::pkcs8::{DecodePrivateKey, EncodePublicKey, LineEnding};
16use rsa::traits::PublicKeyParts;
17use rsa::{Oaep, RsaPrivateKey, RsaPublicKey};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use tempfile::NamedTempFile;
21use zeroize::Zeroizing;
22
23use crate::{Error, Result};
24
25pub const INSPECT_ARCHIVE_CAPABILITY: &str = "admin.diagnostics.inspect-archive";
26pub const INSPECT_ARCHIVE_ROUTE: &str = "/rustfs/admin/v4/inspect/archive";
27pub const INSPECT_ARCHIVE_CONTENT_TYPE: &str = "application/vnd.rustfs.inspect-archive.v1";
28pub const INSPECT_ARCHIVE_ENCRYPTION: &str = "RSA-OAEP-SHA256+AES-256-GCM-CHUNKED";
29pub const INSPECT_ARCHIVE_COMPLETION: &str = "authenticated-final-record-required";
30pub const INSPECT_ARCHIVE_VERSION: u16 = 1;
31pub const MAX_INSPECT_ARCHIVE_BYTES: usize = 16 * 1024 * 1024;
32pub const MAX_INSPECT_ARCHIVE_DURATION: Duration = Duration::from_secs(30);
33pub const MAX_INSPECT_ARCHIVE_METADATA_BYTES_PER_DRIVE: usize = 4 * 1024 * 1024;
34
35const FORMAT_MAGIC: &[u8; 8] = b"RFSINSP1";
36const ARCHIVE_CHUNK_SIZE: usize = 64 * 1024;
37const RECORD_DATA: u8 = 1;
38const RECORD_FINAL: u8 = 2;
39const MAX_MANIFEST_BYTES: u64 = 64 * 1024;
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct InspectArchiveCapabilityContract {
44 pub state: super::RuntimeCapabilityStatus,
45 pub route: String,
46 pub archive_version: u16,
47 pub content_type: String,
48 pub encryption: String,
49 pub completion_contract: String,
50 pub max_bytes: usize,
51 pub max_duration_secs: u64,
52 pub max_metadata_bytes_per_drive: usize,
53 #[serde(flatten, default)]
54 pub extra: BTreeMap<String, serde_json::Value>,
55}
56
57impl InspectArchiveCapabilityContract {
58 pub fn validate(&self) -> Result<()> {
60 if self.state.availability() != super::CapabilityAvailability::Available {
61 return Err(Error::UnsupportedFeature(
62 self.state
63 .reason
64 .clone()
65 .unwrap_or_else(|| "Diagnostic archive capability is unavailable".to_string()),
66 ));
67 }
68 if self.route != INSPECT_ARCHIVE_ROUTE
69 || self.archive_version != INSPECT_ARCHIVE_VERSION
70 || self.content_type != INSPECT_ARCHIVE_CONTENT_TYPE
71 || self.encryption != INSPECT_ARCHIVE_ENCRYPTION
72 || self.completion_contract != INSPECT_ARCHIVE_COMPLETION
73 {
74 return Err(Error::UnsupportedFeature(
75 "RustFS advertised an incompatible diagnostic archive contract".to_string(),
76 ));
77 }
78 if self.max_bytes == 0 || self.max_bytes > MAX_INSPECT_ARCHIVE_BYTES {
79 return Err(Error::RequestRejected(
80 "RustFS advertised an invalid diagnostic archive byte limit".to_string(),
81 ));
82 }
83 if self.max_duration_secs == 0
84 || self.max_duration_secs > MAX_INSPECT_ARCHIVE_DURATION.as_secs()
85 {
86 return Err(Error::RequestRejected(
87 "RustFS advertised an invalid diagnostic archive duration limit".to_string(),
88 ));
89 }
90 if self.max_metadata_bytes_per_drive == 0
91 || self.max_metadata_bytes_per_drive > MAX_INSPECT_ARCHIVE_METADATA_BYTES_PER_DRIVE
92 {
93 return Err(Error::RequestRejected(
94 "RustFS advertised an invalid diagnostic archive metadata limit".to_string(),
95 ));
96 }
97 Ok(())
98 }
99
100 pub fn timeout(&self) -> Duration {
101 Duration::from_secs(self.max_duration_secs)
102 }
103}
104
105pub struct InspectArchiveKey {
107 private_key: RsaPrivateKey,
108 public_key_pem: String,
109}
110
111impl InspectArchiveKey {
112 pub fn generate() -> Result<Self> {
114 let private_key = RsaPrivateKey::new(&mut OsRng, 2048)
115 .map_err(|_| Error::General("Failed to generate diagnostic archive key".to_string()))?;
116 Self::from_private_key(private_key)
117 }
118
119 pub fn from_pkcs8_pem(pem: &str) -> Result<Self> {
121 let private_key = RsaPrivateKey::from_pkcs8_pem(pem).map_err(|_| {
122 Error::InvalidPath(
123 "Diagnostic archive private key must be valid PKCS#8 RSA PEM".to_string(),
124 )
125 })?;
126 Self::from_private_key(private_key)
127 }
128
129 fn from_private_key(private_key: RsaPrivateKey) -> Result<Self> {
130 let bits = private_key.size().saturating_mul(8);
131 if !(2048..=8192).contains(&bits) {
132 return Err(Error::InvalidPath(
133 "Diagnostic archive RSA private key must be between 2048 and 8192 bits".to_string(),
134 ));
135 }
136 let public_key_pem = RsaPublicKey::from(&private_key)
137 .to_public_key_pem(LineEnding::LF)
138 .map_err(|_| {
139 Error::General("Failed to derive diagnostic archive public key".to_string())
140 })?;
141 Ok(Self {
142 private_key,
143 public_key_pem,
144 })
145 }
146
147 pub fn public_key_pem(&self) -> &str {
148 &self.public_key_pem
149 }
150}
151
152pub struct InspectArchiveTransportRequest {
154 pub bucket: String,
155 pub object: String,
156 pub public_key_pem: String,
157 pub max_bytes: usize,
158 pub timeout: Duration,
159}
160
161pub struct EncryptedInspectArchive {
163 pub file: NamedTempFile,
164 pub bytes: u64,
165}
166
167#[async_trait]
169pub trait InspectArchiveApi: Send + Sync {
170 async fn inspect_archive_capability(&self) -> Result<InspectArchiveCapabilityContract>;
171
172 async fn download_inspect_archive(
173 &self,
174 request: InspectArchiveTransportRequest,
175 temporary_directory: &Path,
176 ) -> Result<EncryptedInspectArchive>;
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct InspectArchiveManifest {
181 pub archive_version: u16,
182 pub drive_count: usize,
183 pub artifact_paths: Vec<String>,
184 pub target_identifiers_included: bool,
185 pub raw_object_data_included: bool,
186 pub raw_metadata_included: bool,
187 #[serde(flatten, default)]
188 pub extra: BTreeMap<String, serde_json::Value>,
189}
190
191pub struct VerifiedInspectArchive {
193 file: NamedTempFile,
194 pub manifest: InspectArchiveManifest,
195 pub encrypted_bytes: u64,
196 pub plaintext_bytes: u64,
197 pub plaintext_sha256: String,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct PublishedInspectArchive {
203 pub path: PathBuf,
204 pub archive_version: u16,
205 pub drive_count: usize,
206 pub encrypted_bytes: u64,
207 pub plaintext_bytes: u64,
208 pub plaintext_sha256: String,
209}
210
211#[derive(Clone, Default)]
213pub struct InspectArchiveCancellation {
214 cancelled: Arc<AtomicBool>,
215}
216
217impl InspectArchiveCancellation {
218 pub fn cancel(&self) {
219 self.cancelled.store(true, Ordering::Release);
220 }
221
222 pub fn is_cancelled(&self) -> bool {
223 self.cancelled.load(Ordering::Acquire)
224 }
225}
226
227fn crypto_error(message: &str) -> Error {
228 Error::General(format!("Diagnostic archive verification failed: {message}"))
229}
230
231fn read_exact(reader: &mut impl Read, bytes: &mut [u8], message: &str) -> Result<()> {
232 reader.read_exact(bytes).map_err(|_| crypto_error(message))
233}
234
235fn encryption_aad(record_type: u8, counter: u32) -> [u8; 13] {
236 let mut aad = [0_u8; 13];
237 aad[..8].copy_from_slice(FORMAT_MAGIC);
238 aad[8] = record_type;
239 aad[9..].copy_from_slice(&counter.to_be_bytes());
240 aad
241}
242
243pub fn decrypt_and_validate_inspect_archive(
245 encrypted: EncryptedInspectArchive,
246 key: &InspectArchiveKey,
247 temporary_directory: &Path,
248 max_plaintext_bytes: usize,
249) -> Result<VerifiedInspectArchive> {
250 decrypt_and_validate_inspect_archive_with_cancel(
251 encrypted,
252 key,
253 temporary_directory,
254 max_plaintext_bytes,
255 MAX_INSPECT_ARCHIVE_METADATA_BYTES_PER_DRIVE,
256 max_plaintext_bytes,
257 &InspectArchiveCancellation::default(),
258 )
259}
260
261pub fn decrypt_and_validate_inspect_archive_with_cancel(
262 encrypted: EncryptedInspectArchive,
263 key: &InspectArchiveKey,
264 temporary_directory: &Path,
265 max_plaintext_bytes: usize,
266 max_metadata_bytes_per_drive: usize,
267 max_unpacked_bytes: usize,
268 cancellation: &InspectArchiveCancellation,
269) -> Result<VerifiedInspectArchive> {
270 let mut reader =
271 BufReader::new(encrypted.file.reopen().map_err(|_| {
272 Error::InvalidPath("Failed to open staged diagnostic archive".to_string())
273 })?);
274 let mut fixed_header = [0_u8; 24];
275 read_exact(&mut reader, &mut fixed_header, "truncated encrypted header")?;
276 if &fixed_header[..8] != FORMAT_MAGIC {
277 return Err(crypto_error("invalid encrypted header"));
278 }
279 if u16::from_be_bytes([fixed_header[8], fixed_header[9]]) != INSPECT_ARCHIVE_VERSION {
280 return Err(crypto_error("unsupported archive version"));
281 }
282 let chunk_size = usize::try_from(u32::from_be_bytes(
283 fixed_header[10..14]
284 .try_into()
285 .map_err(|_| crypto_error("invalid chunk size"))?,
286 ))
287 .map_err(|_| crypto_error("invalid chunk size"))?;
288 if chunk_size != ARCHIVE_CHUNK_SIZE {
289 return Err(crypto_error("unexpected encrypted chunk size"));
290 }
291 let wrapped_len = usize::from(u16::from_be_bytes([fixed_header[14], fixed_header[15]]));
292 if wrapped_len == 0 || wrapped_len > 1024 {
293 return Err(crypto_error("invalid wrapped key length"));
294 }
295 let nonce_prefix: [u8; 8] = fixed_header[16..24]
296 .try_into()
297 .map_err(|_| crypto_error("invalid nonce prefix"))?;
298 let mut wrapped_key = vec![0_u8; wrapped_len];
299 read_exact(
300 &mut reader,
301 &mut wrapped_key,
302 "truncated wrapped encryption key",
303 )?;
304 let data_key = Zeroizing::new(
305 key.private_key
306 .decrypt(Oaep::new::<Sha256>(), &wrapped_key)
307 .map_err(|_| crypto_error("RSA key unwrap failed"))?,
308 );
309 let cipher = Aes256Gcm::new_from_slice(&data_key)
310 .map_err(|_| crypto_error("invalid decrypted data key"))?;
311
312 let mut plaintext = NamedTempFile::new_in(temporary_directory).map_err(|_| {
313 Error::InvalidPath("Failed to create private diagnostic archive staging file".to_string())
314 })?;
315 protect_private_file(plaintext.as_file())?;
316 let mut digest = Sha256::new();
317 let mut plaintext_bytes = 0_usize;
318 let mut expected_counter = 0_u32;
319 let final_digest = loop {
320 if cancellation.is_cancelled() {
321 return Err(Error::Interrupted(
322 "Diagnostic archive verification was cancelled".to_string(),
323 ));
324 }
325 let mut record_header = [0_u8; 9];
326 read_exact(
327 &mut reader,
328 &mut record_header,
329 "authenticated final record is missing",
330 )?;
331 let record_type = record_header[0];
332 let counter = u32::from_be_bytes(
333 record_header[1..5]
334 .try_into()
335 .map_err(|_| crypto_error("invalid record counter"))?,
336 );
337 if counter != expected_counter {
338 return Err(crypto_error("record counter is not contiguous"));
339 }
340 let ciphertext_len = usize::try_from(u32::from_be_bytes(
341 record_header[5..9]
342 .try_into()
343 .map_err(|_| crypto_error("invalid record length"))?,
344 ))
345 .map_err(|_| crypto_error("invalid record length"))?;
346 let maximum = match record_type {
347 RECORD_DATA => ARCHIVE_CHUNK_SIZE + 16,
348 RECORD_FINAL => 32 + 16,
349 _ => return Err(crypto_error("unknown encrypted record type")),
350 };
351 if ciphertext_len < 16 || ciphertext_len > maximum {
352 return Err(crypto_error(
353 "encrypted record length is outside the contract",
354 ));
355 }
356 let mut ciphertext = vec![0_u8; ciphertext_len];
357 read_exact(&mut reader, &mut ciphertext, "truncated encrypted record")?;
358 let mut nonce = [0_u8; 12];
359 nonce[..8].copy_from_slice(&nonce_prefix);
360 nonce[8..].copy_from_slice(&counter.to_be_bytes());
361 let decoded = cipher
362 .decrypt(
363 (&nonce).into(),
364 Payload {
365 msg: &ciphertext,
366 aad: &encryption_aad(record_type, counter),
367 },
368 )
369 .map_err(|_| crypto_error("record authentication failed"))?;
370 match record_type {
371 RECORD_DATA => {
372 plaintext_bytes = plaintext_bytes
373 .checked_add(decoded.len())
374 .ok_or_else(|| crypto_error("plaintext byte accounting overflow"))?;
375 if plaintext_bytes > max_plaintext_bytes
376 || plaintext_bytes > MAX_INSPECT_ARCHIVE_BYTES
377 {
378 return Err(crypto_error("plaintext archive exceeds the client limit"));
379 }
380 digest.update(&decoded);
381 plaintext.write_all(&decoded).map_err(|_| {
382 Error::InvalidPath(
383 "Failed to write private diagnostic archive staging file".to_string(),
384 )
385 })?;
386 expected_counter = expected_counter
387 .checked_add(1)
388 .ok_or_else(|| crypto_error("record counter exhausted"))?;
389 }
390 RECORD_FINAL => {
391 if decoded.len() != 32 || decoded.as_slice() != digest.clone().finalize().as_slice()
392 {
393 return Err(crypto_error("final plaintext digest mismatch"));
394 }
395 break decoded;
396 }
397 _ => unreachable!(),
398 }
399 };
400 let mut trailing = [0_u8; 1];
401 if reader
402 .read(&mut trailing)
403 .map_err(|_| crypto_error("failed to inspect encrypted completion"))?
404 != 0
405 {
406 return Err(crypto_error("records follow the authenticated completion"));
407 }
408 plaintext
409 .as_file_mut()
410 .sync_all()
411 .map_err(|_| Error::InvalidPath("Failed to sync diagnostic archive staging file".into()))?;
412 plaintext
413 .as_file_mut()
414 .seek(SeekFrom::Start(0))
415 .map_err(|_| crypto_error("failed to rewind plaintext archive"))?;
416 let manifest = validate_plaintext_archive(
417 plaintext.as_file_mut(),
418 plaintext_bytes,
419 max_metadata_bytes_per_drive,
420 max_unpacked_bytes,
421 cancellation,
422 )?;
423
424 Ok(VerifiedInspectArchive {
425 file: plaintext,
426 manifest,
427 encrypted_bytes: encrypted.bytes,
428 plaintext_bytes: u64::try_from(plaintext_bytes)
429 .map_err(|_| crypto_error("plaintext byte accounting overflow"))?,
430 plaintext_sha256: hex::encode(final_digest),
431 })
432}
433
434fn validate_plaintext_archive(
435 file: &mut File,
436 expected_plaintext_bytes: usize,
437 max_metadata_bytes_per_drive: usize,
438 max_unpacked_bytes: usize,
439 cancellation: &InspectArchiveCancellation,
440) -> Result<InspectArchiveManifest> {
441 file.seek(SeekFrom::Start(0))
442 .map_err(|_| crypto_error("failed to inspect plaintext archive"))?;
443 let mut archive = tar::Archive::new(file);
444 let mut entries = archive
445 .entries()
446 .map_err(|_| crypto_error("plaintext tar is malformed"))?;
447 let Some(first) = entries.next() else {
448 return Err(crypto_error("plaintext tar is empty"));
449 };
450 let mut first = first.map_err(|_| crypto_error("plaintext tar is malformed"))?;
451 if !first.header().entry_type().is_file()
452 || first
453 .path()
454 .map_err(|_| crypto_error("manifest path is malformed"))?
455 .as_ref()
456 != Path::new("manifest.json")
457 || first.size() > MAX_MANIFEST_BYTES
458 {
459 return Err(crypto_error("manifest entry is invalid"));
460 }
461 let mut manifest_bytes = Vec::new();
462 first
463 .read_to_end(&mut manifest_bytes)
464 .map_err(|_| crypto_error("manifest entry is truncated"))?;
465 let manifest: InspectArchiveManifest = serde_json::from_slice(&manifest_bytes)
466 .map_err(|_| crypto_error("manifest JSON is invalid"))?;
467 validate_manifest(&manifest)?;
468 let mut unpacked_bytes = manifest_bytes.len();
469 if unpacked_bytes > max_unpacked_bytes {
470 return Err(crypto_error("unpacked archive exceeds the client limit"));
471 }
472
473 let expected = manifest
474 .artifact_paths
475 .iter()
476 .cloned()
477 .collect::<BTreeSet<_>>();
478 let mut observed = BTreeSet::new();
479 for entry in entries {
480 if cancellation.is_cancelled() {
481 return Err(Error::Interrupted(
482 "Diagnostic archive verification was cancelled".to_string(),
483 ));
484 }
485 let mut entry = entry.map_err(|_| crypto_error("plaintext tar is malformed"))?;
486 if !entry.header().entry_type().is_file()
487 || entry.size()
488 > u64::try_from(max_metadata_bytes_per_drive)
489 .map_err(|_| crypto_error("invalid drive metadata limit"))?
490 {
491 return Err(crypto_error("drive artifact entry is invalid"));
492 }
493 let path = entry
494 .path()
495 .map_err(|_| crypto_error("drive artifact path is malformed"))?
496 .to_string_lossy()
497 .into_owned();
498 if !expected.contains(&path) || !observed.insert(path) {
499 return Err(crypto_error("plaintext tar contains an unexpected entry"));
500 }
501 let mut artifact = Vec::new();
502 entry
503 .read_to_end(&mut artifact)
504 .map_err(|_| crypto_error("drive artifact entry is truncated"))?;
505 unpacked_bytes = unpacked_bytes
506 .checked_add(artifact.len())
507 .ok_or_else(|| crypto_error("unpacked byte accounting overflow"))?;
508 if unpacked_bytes > max_unpacked_bytes {
509 return Err(crypto_error("unpacked archive exceeds the client limit"));
510 }
511 let value: serde_json::Value = serde_json::from_slice(&artifact)
512 .map_err(|_| crypto_error("drive artifact JSON is invalid"))?;
513 if !value.is_object() {
514 return Err(crypto_error("drive artifact JSON is not an object"));
515 }
516 }
517 if observed != expected {
518 return Err(crypto_error("plaintext tar is missing drive artifacts"));
519 }
520 if archive
521 .into_inner()
522 .metadata()
523 .map_err(|_| crypto_error("failed to inspect plaintext archive"))?
524 .len()
525 != u64::try_from(expected_plaintext_bytes)
526 .map_err(|_| crypto_error("plaintext byte accounting overflow"))?
527 {
528 return Err(crypto_error(
529 "plaintext archive length changed during verification",
530 ));
531 }
532 Ok(manifest)
533}
534
535fn validate_manifest(manifest: &InspectArchiveManifest) -> Result<()> {
536 if manifest.archive_version != INSPECT_ARCHIVE_VERSION
537 || manifest.target_identifiers_included
538 || manifest.raw_object_data_included
539 || manifest.raw_metadata_included
540 || manifest.drive_count != manifest.artifact_paths.len()
541 {
542 return Err(crypto_error("manifest safety contract is invalid"));
543 }
544 let expected = (0..manifest.drive_count)
545 .map(|index| format!("drives/{index:04}.json"))
546 .collect::<Vec<_>>();
547 if manifest.artifact_paths != expected {
548 return Err(crypto_error("manifest artifact paths are invalid"));
549 }
550 Ok(())
551}
552
553fn protect_private_file(file: &File) -> Result<()> {
554 #[cfg(unix)]
555 {
556 use std::os::unix::fs::PermissionsExt;
557 file.set_permissions(std::fs::Permissions::from_mode(0o600))
558 .map_err(|_| {
559 Error::InvalidPath("Failed to protect diagnostic archive staging file".to_string())
560 })?;
561 }
562 Ok(())
563}
564
565pub fn validate_inspect_archive_output_directory(directory: &Path) -> Result<()> {
567 let mut current = if directory.is_absolute() {
568 PathBuf::new()
569 } else {
570 PathBuf::from(".")
571 };
572 let mut normal_depth = 0_usize;
573 for component in directory.components() {
574 match component {
575 Component::Prefix(_) | Component::RootDir => {
576 current.push(component.as_os_str());
577 }
578 Component::CurDir => continue,
579 Component::ParentDir => {
580 return Err(Error::InvalidPath(
581 "Diagnostic archive output directory cannot contain parent traversal"
582 .to_string(),
583 ));
584 }
585 Component::Normal(part) => {
586 normal_depth += 1;
587 current.push(part);
588 let metadata = std::fs::symlink_metadata(¤t).map_err(|_| {
589 Error::InvalidPath(
590 "Diagnostic archive output directory does not exist or is inaccessible"
591 .to_string(),
592 )
593 })?;
594 let platform_root_alias = directory.is_absolute() && normal_depth == 1;
596 if metadata.file_type().is_symlink() && !platform_root_alias {
597 return Err(Error::InvalidPath(
598 "Diagnostic archive output directory cannot contain symbolic links"
599 .to_string(),
600 ));
601 }
602 }
603 }
604 }
605 let metadata = std::fs::symlink_metadata(directory).map_err(|_| {
606 Error::InvalidPath(
607 "Diagnostic archive output directory does not exist or is inaccessible".to_string(),
608 )
609 })?;
610 let platform_root_alias = directory.is_absolute()
611 && directory
612 .components()
613 .filter(|component| matches!(component, Component::Normal(_)))
614 .count()
615 == 1;
616 if metadata.file_type().is_symlink() && !platform_root_alias {
617 return Err(Error::InvalidPath(
618 "Diagnostic archive output directory cannot be a symbolic link".to_string(),
619 ));
620 }
621 let is_directory = if metadata.file_type().is_symlink() {
622 std::fs::metadata(directory)
623 .map(|target| target.is_dir())
624 .unwrap_or(false)
625 } else {
626 metadata.is_dir()
627 };
628 if !is_directory {
629 return Err(Error::InvalidPath(
630 "Diagnostic archive output directory does not exist".to_string(),
631 ));
632 }
633 Ok(())
634}
635
636pub fn publish_inspect_archive(
638 verified: VerifiedInspectArchive,
639 destination: &Path,
640) -> Result<PublishedInspectArchive> {
641 let parent = destination
642 .parent()
643 .filter(|path| !path.as_os_str().is_empty())
644 .unwrap_or_else(|| Path::new("."));
645 validate_inspect_archive_output_directory(parent)?;
646 if std::fs::symlink_metadata(destination).is_ok() {
647 return Err(Error::Conflict(
648 "Diagnostic archive output already exists".to_string(),
649 ));
650 }
651 let VerifiedInspectArchive {
652 file,
653 manifest,
654 encrypted_bytes,
655 plaintext_bytes,
656 plaintext_sha256,
657 } = verified;
658 file.persist_noclobber(destination).map_err(|error| {
659 if error.error.kind() == std::io::ErrorKind::AlreadyExists {
660 Error::Conflict("Diagnostic archive output already exists".to_string())
661 } else {
662 Error::InvalidPath("Failed to atomically publish diagnostic archive output".to_string())
663 }
664 })?;
665 #[cfg(unix)]
666 if File::open(parent)
667 .and_then(|directory| directory.sync_all())
668 .is_err()
669 {
670 let _ = std::fs::remove_file(destination);
671 return Err(Error::InvalidPath(
672 "Failed to sync diagnostic archive output directory".to_string(),
673 ));
674 }
675 Ok(PublishedInspectArchive {
676 path: destination.to_path_buf(),
677 archive_version: manifest.archive_version,
678 drive_count: manifest.drive_count,
679 encrypted_bytes,
680 plaintext_bytes,
681 plaintext_sha256,
682 })
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use aes_gcm::Key;
689 use rsa::pkcs8::DecodePublicKey;
690 use tempfile::tempdir;
691
692 fn manifest(raw_metadata: bool) -> InspectArchiveManifest {
693 InspectArchiveManifest {
694 archive_version: 1,
695 drive_count: 1,
696 artifact_paths: vec!["drives/0000.json".to_string()],
697 target_identifiers_included: false,
698 raw_object_data_included: false,
699 raw_metadata_included: raw_metadata,
700 extra: BTreeMap::new(),
701 }
702 }
703
704 fn tar_fixture(manifest: &InspectArchiveManifest) -> Vec<u8> {
705 let mut output = Vec::new();
706 {
707 let mut archive = tar::Builder::new(&mut output);
708 for (path, bytes) in [
709 (
710 "manifest.json",
711 serde_json::to_vec(manifest).expect("serialize manifest"),
712 ),
713 (
714 "drives/0000.json",
715 br#"{"drive_index":0,"status":"ok"}"#.to_vec(),
716 ),
717 ] {
718 let mut header = tar::Header::new_gnu();
719 header.set_mode(0o600);
720 header.set_mtime(0);
721 header.set_size(bytes.len() as u64);
722 header.set_cksum();
723 archive
724 .append_data(&mut header, path, bytes.as_slice())
725 .expect("append tar entry");
726 }
727 archive.finish().expect("finish tar");
728 }
729 output
730 }
731
732 fn encrypt_fixture(key: &InspectArchiveKey, plaintext: &[u8]) -> Vec<u8> {
733 let public =
734 RsaPublicKey::from_public_key_pem(key.public_key_pem()).expect("parse public key");
735 let data_key = [7_u8; 32];
736 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&data_key));
737 let wrapped = public
738 .encrypt(&mut OsRng, Oaep::new::<Sha256>(), &data_key)
739 .expect("wrap key");
740 let nonce_prefix = [9_u8; 8];
741 let mut output = Vec::new();
742 output.extend_from_slice(FORMAT_MAGIC);
743 output.extend_from_slice(&INSPECT_ARCHIVE_VERSION.to_be_bytes());
744 output.extend_from_slice(&(ARCHIVE_CHUNK_SIZE as u32).to_be_bytes());
745 output.extend_from_slice(&(wrapped.len() as u16).to_be_bytes());
746 output.extend_from_slice(&nonce_prefix);
747 output.extend_from_slice(&wrapped);
748
749 for (counter, chunk) in plaintext.chunks(ARCHIVE_CHUNK_SIZE).enumerate() {
750 append_encrypted_record(
751 &mut output,
752 &cipher,
753 &nonce_prefix,
754 RECORD_DATA,
755 counter as u32,
756 chunk,
757 );
758 }
759 append_encrypted_record(
760 &mut output,
761 &cipher,
762 &nonce_prefix,
763 RECORD_FINAL,
764 plaintext.len().div_ceil(ARCHIVE_CHUNK_SIZE) as u32,
765 &Sha256::digest(plaintext),
766 );
767 output
768 }
769
770 fn append_encrypted_record(
771 output: &mut Vec<u8>,
772 cipher: &Aes256Gcm,
773 nonce_prefix: &[u8; 8],
774 record_type: u8,
775 counter: u32,
776 plaintext: &[u8],
777 ) {
778 let mut nonce = [0_u8; 12];
779 nonce[..8].copy_from_slice(nonce_prefix);
780 nonce[8..].copy_from_slice(&counter.to_be_bytes());
781 let ciphertext = cipher
782 .encrypt(
783 (&nonce).into(),
784 Payload {
785 msg: plaintext,
786 aad: &encryption_aad(record_type, counter),
787 },
788 )
789 .expect("encrypt record");
790 output.push(record_type);
791 output.extend_from_slice(&counter.to_be_bytes());
792 output.extend_from_slice(&(ciphertext.len() as u32).to_be_bytes());
793 output.extend_from_slice(&ciphertext);
794 }
795
796 fn staged(bytes: &[u8], directory: &Path) -> EncryptedInspectArchive {
797 let mut file = NamedTempFile::new_in(directory).expect("create encrypted staging");
798 file.write_all(bytes).expect("write encrypted staging");
799 EncryptedInspectArchive {
800 file,
801 bytes: bytes.len() as u64,
802 }
803 }
804
805 #[test]
806 fn complete_archive_decrypts_validates_and_publishes_privately() {
807 let directory = tempdir().expect("temp directory");
808 let key = InspectArchiveKey::generate().expect("key");
809 let plaintext = tar_fixture(&manifest(false));
810 let encrypted = encrypt_fixture(&key, &plaintext);
811 let verified = decrypt_and_validate_inspect_archive(
812 staged(&encrypted, directory.path()),
813 &key,
814 directory.path(),
815 MAX_INSPECT_ARCHIVE_BYTES,
816 )
817 .expect("verified archive");
818 assert_eq!(verified.manifest.drive_count, 1);
819 assert_eq!(verified.plaintext_bytes, plaintext.len() as u64);
820 let destination = directory.path().join("inspect.tar");
821 let published =
822 publish_inspect_archive(verified, &destination).expect("publish verified archive");
823 assert_eq!(published.path, destination);
824 assert_eq!(std::fs::read(&destination).expect("read output"), plaintext);
825 #[cfg(unix)]
826 {
827 use std::os::unix::fs::PermissionsExt;
828 assert_eq!(
829 std::fs::metadata(destination)
830 .expect("metadata")
831 .permissions()
832 .mode()
833 & 0o077,
834 0
835 );
836 }
837 }
838
839 #[test]
840 fn corruption_truncation_and_missing_completion_leave_no_plaintext() {
841 let directory = tempdir().expect("temp directory");
842 let key = InspectArchiveKey::generate().expect("key");
843 let plaintext = tar_fixture(&manifest(false));
844 let encrypted = encrypt_fixture(&key, &plaintext);
845 let cases = [
846 encrypted[..encrypted.len() - 1].to_vec(),
847 {
848 let mut corrupt = encrypted.clone();
849 let last = corrupt.last_mut().expect("ciphertext");
850 *last ^= 1;
851 corrupt
852 },
853 encrypted[..encrypted.len() - 57].to_vec(),
854 ];
855 for bytes in cases {
856 let before = std::fs::read_dir(directory.path())
857 .expect("read directory")
858 .count();
859 let error = decrypt_and_validate_inspect_archive(
860 staged(&bytes, directory.path()),
861 &key,
862 directory.path(),
863 MAX_INSPECT_ARCHIVE_BYTES,
864 )
865 .err()
866 .expect("invalid archive");
867 assert!(error.to_string().contains("verification failed"));
868 let after = std::fs::read_dir(directory.path())
869 .expect("read directory")
870 .count();
871 assert_eq!(before, after);
872 }
873 }
874
875 #[test]
876 fn unsafe_manifest_limit_and_cancellation_are_rejected() {
877 let directory = tempdir().expect("temp directory");
878 let key = InspectArchiveKey::generate().expect("key");
879 let unsafe_tar = tar_fixture(&manifest(true));
880 let error = decrypt_and_validate_inspect_archive(
881 staged(&encrypt_fixture(&key, &unsafe_tar), directory.path()),
882 &key,
883 directory.path(),
884 MAX_INSPECT_ARCHIVE_BYTES,
885 )
886 .err()
887 .expect("unsafe manifest");
888 assert!(error.to_string().contains("manifest safety"));
889
890 let valid_tar = tar_fixture(&manifest(false));
891 let error = decrypt_and_validate_inspect_archive(
892 staged(&encrypt_fixture(&key, &valid_tar), directory.path()),
893 &key,
894 directory.path(),
895 valid_tar.len() - 1,
896 )
897 .err()
898 .expect("plaintext limit");
899 assert!(error.to_string().contains("client limit"));
900
901 let cancellation = InspectArchiveCancellation::default();
902 cancellation.cancel();
903 let error = decrypt_and_validate_inspect_archive_with_cancel(
904 staged(&encrypt_fixture(&key, &valid_tar), directory.path()),
905 &key,
906 directory.path(),
907 MAX_INSPECT_ARCHIVE_BYTES,
908 MAX_INSPECT_ARCHIVE_METADATA_BYTES_PER_DRIVE,
909 MAX_INSPECT_ARCHIVE_BYTES,
910 &cancellation,
911 )
912 .err()
913 .expect("cancelled verification");
914 assert!(matches!(error, Error::Interrupted(_)));
915
916 let error = decrypt_and_validate_inspect_archive_with_cancel(
917 staged(&encrypt_fixture(&key, &valid_tar), directory.path()),
918 &key,
919 directory.path(),
920 MAX_INSPECT_ARCHIVE_BYTES,
921 MAX_INSPECT_ARCHIVE_METADATA_BYTES_PER_DRIVE,
922 1,
923 &InspectArchiveCancellation::default(),
924 )
925 .err()
926 .expect("unpacked limit");
927 assert!(error.to_string().contains("unpacked archive"));
928 }
929
930 #[test]
931 fn publish_refuses_overwrite_and_removes_staging_file() {
932 let directory = tempdir().expect("temp directory");
933 let key = InspectArchiveKey::generate().expect("key");
934 let plaintext = tar_fixture(&manifest(false));
935 let verified = decrypt_and_validate_inspect_archive(
936 staged(&encrypt_fixture(&key, &plaintext), directory.path()),
937 &key,
938 directory.path(),
939 MAX_INSPECT_ARCHIVE_BYTES,
940 )
941 .expect("verified archive");
942 let staging = verified.file.path().to_path_buf();
943 let destination = directory.path().join("existing.tar");
944 std::fs::write(&destination, b"keep").expect("existing output");
945 let error = publish_inspect_archive(verified, &destination).expect_err("no overwrite");
946 assert!(matches!(error, Error::Conflict(_)));
947 assert_eq!(
948 std::fs::read(destination).expect("existing output"),
949 b"keep"
950 );
951 assert!(!staging.exists());
952 }
953
954 #[cfg(unix)]
955 #[test]
956 fn output_directory_and_broken_destination_symlinks_are_rejected() {
957 use std::os::unix::fs::symlink;
958
959 let tmp = Path::new("/tmp");
960 if std::fs::symlink_metadata(tmp)
961 .map(|metadata| metadata.file_type().is_symlink())
962 .unwrap_or(false)
963 {
964 assert!(validate_inspect_archive_output_directory(tmp).is_ok());
965 }
966
967 let directory = tempdir().expect("temp directory");
968 let real = directory.path().join("real");
969 std::fs::create_dir(&real).expect("real directory");
970 let child = real.join("child");
971 std::fs::create_dir(&child).expect("real child directory");
972 let linked = directory.path().join("linked");
973 symlink(&real, &linked).expect("directory symlink");
974 assert!(validate_inspect_archive_output_directory(&linked).is_err());
975 assert!(validate_inspect_archive_output_directory(&linked.join("child")).is_err());
976
977 let destination = real.join("archive.tar");
978 symlink(real.join("missing"), &destination).expect("broken destination symlink");
979 let key = InspectArchiveKey::generate().expect("key");
980 let plaintext = tar_fixture(&manifest(false));
981 let verified = decrypt_and_validate_inspect_archive(
982 staged(&encrypt_fixture(&key, &plaintext), &real),
983 &key,
984 &real,
985 MAX_INSPECT_ARCHIVE_BYTES,
986 )
987 .expect("verified archive");
988 assert!(matches!(
989 publish_inspect_archive(verified, &destination),
990 Err(Error::Conflict(_))
991 ));
992 }
993
994 #[test]
995 fn capability_and_private_key_contracts_fail_closed() {
996 let supported = InspectArchiveCapabilityContract {
997 state: super::super::RuntimeCapabilityStatus {
998 state: super::super::RuntimeCapabilityState::Supported,
999 reason: None,
1000 extra: BTreeMap::new(),
1001 },
1002 route: INSPECT_ARCHIVE_ROUTE.to_string(),
1003 archive_version: 1,
1004 content_type: INSPECT_ARCHIVE_CONTENT_TYPE.to_string(),
1005 encryption: INSPECT_ARCHIVE_ENCRYPTION.to_string(),
1006 completion_contract: INSPECT_ARCHIVE_COMPLETION.to_string(),
1007 max_bytes: MAX_INSPECT_ARCHIVE_BYTES,
1008 max_duration_secs: 30,
1009 max_metadata_bytes_per_drive: 4 * 1024 * 1024,
1010 extra: BTreeMap::new(),
1011 };
1012 supported.validate().expect("supported contract");
1013 let mut incompatible = supported;
1014 incompatible.encryption = "future".to_string();
1015 assert!(matches!(
1016 incompatible.validate(),
1017 Err(Error::UnsupportedFeature(_))
1018 ));
1019 assert!(InspectArchiveKey::from_pkcs8_pem("not-a-key").is_err());
1020 }
1021}