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 if metadata.file_type().is_symlink() {
611 return Err(Error::InvalidPath(
612 "Diagnostic archive output directory cannot be a symbolic link".to_string(),
613 ));
614 }
615 if !metadata.is_dir() {
616 return Err(Error::InvalidPath(
617 "Diagnostic archive output directory does not exist".to_string(),
618 ));
619 }
620 Ok(())
621}
622
623pub fn publish_inspect_archive(
625 verified: VerifiedInspectArchive,
626 destination: &Path,
627) -> Result<PublishedInspectArchive> {
628 let parent = destination
629 .parent()
630 .filter(|path| !path.as_os_str().is_empty())
631 .unwrap_or_else(|| Path::new("."));
632 validate_inspect_archive_output_directory(parent)?;
633 if std::fs::symlink_metadata(destination).is_ok() {
634 return Err(Error::Conflict(
635 "Diagnostic archive output already exists".to_string(),
636 ));
637 }
638 let VerifiedInspectArchive {
639 file,
640 manifest,
641 encrypted_bytes,
642 plaintext_bytes,
643 plaintext_sha256,
644 } = verified;
645 file.persist_noclobber(destination).map_err(|error| {
646 if error.error.kind() == std::io::ErrorKind::AlreadyExists {
647 Error::Conflict("Diagnostic archive output already exists".to_string())
648 } else {
649 Error::InvalidPath("Failed to atomically publish diagnostic archive output".to_string())
650 }
651 })?;
652 #[cfg(unix)]
653 if File::open(parent)
654 .and_then(|directory| directory.sync_all())
655 .is_err()
656 {
657 let _ = std::fs::remove_file(destination);
658 return Err(Error::InvalidPath(
659 "Failed to sync diagnostic archive output directory".to_string(),
660 ));
661 }
662 Ok(PublishedInspectArchive {
663 path: destination.to_path_buf(),
664 archive_version: manifest.archive_version,
665 drive_count: manifest.drive_count,
666 encrypted_bytes,
667 plaintext_bytes,
668 plaintext_sha256,
669 })
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675 use aes_gcm::Key;
676 use rsa::pkcs8::DecodePublicKey;
677 use tempfile::tempdir;
678
679 fn manifest(raw_metadata: bool) -> InspectArchiveManifest {
680 InspectArchiveManifest {
681 archive_version: 1,
682 drive_count: 1,
683 artifact_paths: vec!["drives/0000.json".to_string()],
684 target_identifiers_included: false,
685 raw_object_data_included: false,
686 raw_metadata_included: raw_metadata,
687 extra: BTreeMap::new(),
688 }
689 }
690
691 fn tar_fixture(manifest: &InspectArchiveManifest) -> Vec<u8> {
692 let mut output = Vec::new();
693 {
694 let mut archive = tar::Builder::new(&mut output);
695 for (path, bytes) in [
696 (
697 "manifest.json",
698 serde_json::to_vec(manifest).expect("serialize manifest"),
699 ),
700 (
701 "drives/0000.json",
702 br#"{"drive_index":0,"status":"ok"}"#.to_vec(),
703 ),
704 ] {
705 let mut header = tar::Header::new_gnu();
706 header.set_mode(0o600);
707 header.set_mtime(0);
708 header.set_size(bytes.len() as u64);
709 header.set_cksum();
710 archive
711 .append_data(&mut header, path, bytes.as_slice())
712 .expect("append tar entry");
713 }
714 archive.finish().expect("finish tar");
715 }
716 output
717 }
718
719 fn encrypt_fixture(key: &InspectArchiveKey, plaintext: &[u8]) -> Vec<u8> {
720 let public =
721 RsaPublicKey::from_public_key_pem(key.public_key_pem()).expect("parse public key");
722 let data_key = [7_u8; 32];
723 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&data_key));
724 let wrapped = public
725 .encrypt(&mut OsRng, Oaep::new::<Sha256>(), &data_key)
726 .expect("wrap key");
727 let nonce_prefix = [9_u8; 8];
728 let mut output = Vec::new();
729 output.extend_from_slice(FORMAT_MAGIC);
730 output.extend_from_slice(&INSPECT_ARCHIVE_VERSION.to_be_bytes());
731 output.extend_from_slice(&(ARCHIVE_CHUNK_SIZE as u32).to_be_bytes());
732 output.extend_from_slice(&(wrapped.len() as u16).to_be_bytes());
733 output.extend_from_slice(&nonce_prefix);
734 output.extend_from_slice(&wrapped);
735
736 for (counter, chunk) in plaintext.chunks(ARCHIVE_CHUNK_SIZE).enumerate() {
737 append_encrypted_record(
738 &mut output,
739 &cipher,
740 &nonce_prefix,
741 RECORD_DATA,
742 counter as u32,
743 chunk,
744 );
745 }
746 append_encrypted_record(
747 &mut output,
748 &cipher,
749 &nonce_prefix,
750 RECORD_FINAL,
751 plaintext.len().div_ceil(ARCHIVE_CHUNK_SIZE) as u32,
752 &Sha256::digest(plaintext),
753 );
754 output
755 }
756
757 fn append_encrypted_record(
758 output: &mut Vec<u8>,
759 cipher: &Aes256Gcm,
760 nonce_prefix: &[u8; 8],
761 record_type: u8,
762 counter: u32,
763 plaintext: &[u8],
764 ) {
765 let mut nonce = [0_u8; 12];
766 nonce[..8].copy_from_slice(nonce_prefix);
767 nonce[8..].copy_from_slice(&counter.to_be_bytes());
768 let ciphertext = cipher
769 .encrypt(
770 (&nonce).into(),
771 Payload {
772 msg: plaintext,
773 aad: &encryption_aad(record_type, counter),
774 },
775 )
776 .expect("encrypt record");
777 output.push(record_type);
778 output.extend_from_slice(&counter.to_be_bytes());
779 output.extend_from_slice(&(ciphertext.len() as u32).to_be_bytes());
780 output.extend_from_slice(&ciphertext);
781 }
782
783 fn staged(bytes: &[u8], directory: &Path) -> EncryptedInspectArchive {
784 let mut file = NamedTempFile::new_in(directory).expect("create encrypted staging");
785 file.write_all(bytes).expect("write encrypted staging");
786 EncryptedInspectArchive {
787 file,
788 bytes: bytes.len() as u64,
789 }
790 }
791
792 #[test]
793 fn complete_archive_decrypts_validates_and_publishes_privately() {
794 let directory = tempdir().expect("temp directory");
795 let key = InspectArchiveKey::generate().expect("key");
796 let plaintext = tar_fixture(&manifest(false));
797 let encrypted = encrypt_fixture(&key, &plaintext);
798 let verified = decrypt_and_validate_inspect_archive(
799 staged(&encrypted, directory.path()),
800 &key,
801 directory.path(),
802 MAX_INSPECT_ARCHIVE_BYTES,
803 )
804 .expect("verified archive");
805 assert_eq!(verified.manifest.drive_count, 1);
806 assert_eq!(verified.plaintext_bytes, plaintext.len() as u64);
807 let destination = directory.path().join("inspect.tar");
808 let published =
809 publish_inspect_archive(verified, &destination).expect("publish verified archive");
810 assert_eq!(published.path, destination);
811 assert_eq!(std::fs::read(&destination).expect("read output"), plaintext);
812 #[cfg(unix)]
813 {
814 use std::os::unix::fs::PermissionsExt;
815 assert_eq!(
816 std::fs::metadata(destination)
817 .expect("metadata")
818 .permissions()
819 .mode()
820 & 0o077,
821 0
822 );
823 }
824 }
825
826 #[test]
827 fn corruption_truncation_and_missing_completion_leave_no_plaintext() {
828 let directory = tempdir().expect("temp directory");
829 let key = InspectArchiveKey::generate().expect("key");
830 let plaintext = tar_fixture(&manifest(false));
831 let encrypted = encrypt_fixture(&key, &plaintext);
832 let cases = [
833 encrypted[..encrypted.len() - 1].to_vec(),
834 {
835 let mut corrupt = encrypted.clone();
836 let last = corrupt.last_mut().expect("ciphertext");
837 *last ^= 1;
838 corrupt
839 },
840 encrypted[..encrypted.len() - 57].to_vec(),
841 ];
842 for bytes in cases {
843 let before = std::fs::read_dir(directory.path())
844 .expect("read directory")
845 .count();
846 let error = decrypt_and_validate_inspect_archive(
847 staged(&bytes, directory.path()),
848 &key,
849 directory.path(),
850 MAX_INSPECT_ARCHIVE_BYTES,
851 )
852 .err()
853 .expect("invalid archive");
854 assert!(error.to_string().contains("verification failed"));
855 let after = std::fs::read_dir(directory.path())
856 .expect("read directory")
857 .count();
858 assert_eq!(before, after);
859 }
860 }
861
862 #[test]
863 fn unsafe_manifest_limit_and_cancellation_are_rejected() {
864 let directory = tempdir().expect("temp directory");
865 let key = InspectArchiveKey::generate().expect("key");
866 let unsafe_tar = tar_fixture(&manifest(true));
867 let error = decrypt_and_validate_inspect_archive(
868 staged(&encrypt_fixture(&key, &unsafe_tar), directory.path()),
869 &key,
870 directory.path(),
871 MAX_INSPECT_ARCHIVE_BYTES,
872 )
873 .err()
874 .expect("unsafe manifest");
875 assert!(error.to_string().contains("manifest safety"));
876
877 let valid_tar = tar_fixture(&manifest(false));
878 let error = decrypt_and_validate_inspect_archive(
879 staged(&encrypt_fixture(&key, &valid_tar), directory.path()),
880 &key,
881 directory.path(),
882 valid_tar.len() - 1,
883 )
884 .err()
885 .expect("plaintext limit");
886 assert!(error.to_string().contains("client limit"));
887
888 let cancellation = InspectArchiveCancellation::default();
889 cancellation.cancel();
890 let error = decrypt_and_validate_inspect_archive_with_cancel(
891 staged(&encrypt_fixture(&key, &valid_tar), directory.path()),
892 &key,
893 directory.path(),
894 MAX_INSPECT_ARCHIVE_BYTES,
895 MAX_INSPECT_ARCHIVE_METADATA_BYTES_PER_DRIVE,
896 MAX_INSPECT_ARCHIVE_BYTES,
897 &cancellation,
898 )
899 .err()
900 .expect("cancelled verification");
901 assert!(matches!(error, Error::Interrupted(_)));
902
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 1,
910 &InspectArchiveCancellation::default(),
911 )
912 .err()
913 .expect("unpacked limit");
914 assert!(error.to_string().contains("unpacked archive"));
915 }
916
917 #[test]
918 fn publish_refuses_overwrite_and_removes_staging_file() {
919 let directory = tempdir().expect("temp directory");
920 let key = InspectArchiveKey::generate().expect("key");
921 let plaintext = tar_fixture(&manifest(false));
922 let verified = decrypt_and_validate_inspect_archive(
923 staged(&encrypt_fixture(&key, &plaintext), directory.path()),
924 &key,
925 directory.path(),
926 MAX_INSPECT_ARCHIVE_BYTES,
927 )
928 .expect("verified archive");
929 let staging = verified.file.path().to_path_buf();
930 let destination = directory.path().join("existing.tar");
931 std::fs::write(&destination, b"keep").expect("existing output");
932 let error = publish_inspect_archive(verified, &destination).expect_err("no overwrite");
933 assert!(matches!(error, Error::Conflict(_)));
934 assert_eq!(
935 std::fs::read(destination).expect("existing output"),
936 b"keep"
937 );
938 assert!(!staging.exists());
939 }
940
941 #[cfg(unix)]
942 #[test]
943 fn output_directory_and_broken_destination_symlinks_are_rejected() {
944 use std::os::unix::fs::symlink;
945
946 let directory = tempdir().expect("temp directory");
947 let real = directory.path().join("real");
948 std::fs::create_dir(&real).expect("real directory");
949 let child = real.join("child");
950 std::fs::create_dir(&child).expect("real child directory");
951 let linked = directory.path().join("linked");
952 symlink(&real, &linked).expect("directory symlink");
953 assert!(validate_inspect_archive_output_directory(&linked).is_err());
954 assert!(validate_inspect_archive_output_directory(&linked.join("child")).is_err());
955
956 let destination = real.join("archive.tar");
957 symlink(real.join("missing"), &destination).expect("broken destination symlink");
958 let key = InspectArchiveKey::generate().expect("key");
959 let plaintext = tar_fixture(&manifest(false));
960 let verified = decrypt_and_validate_inspect_archive(
961 staged(&encrypt_fixture(&key, &plaintext), &real),
962 &key,
963 &real,
964 MAX_INSPECT_ARCHIVE_BYTES,
965 )
966 .expect("verified archive");
967 assert!(matches!(
968 publish_inspect_archive(verified, &destination),
969 Err(Error::Conflict(_))
970 ));
971 }
972
973 #[test]
974 fn capability_and_private_key_contracts_fail_closed() {
975 let supported = InspectArchiveCapabilityContract {
976 state: super::super::RuntimeCapabilityStatus {
977 state: super::super::RuntimeCapabilityState::Supported,
978 reason: None,
979 extra: BTreeMap::new(),
980 },
981 route: INSPECT_ARCHIVE_ROUTE.to_string(),
982 archive_version: 1,
983 content_type: INSPECT_ARCHIVE_CONTENT_TYPE.to_string(),
984 encryption: INSPECT_ARCHIVE_ENCRYPTION.to_string(),
985 completion_contract: INSPECT_ARCHIVE_COMPLETION.to_string(),
986 max_bytes: MAX_INSPECT_ARCHIVE_BYTES,
987 max_duration_secs: 30,
988 max_metadata_bytes_per_drive: 4 * 1024 * 1024,
989 extra: BTreeMap::new(),
990 };
991 supported.validate().expect("supported contract");
992 let mut incompatible = supported;
993 incompatible.encryption = "future".to_string();
994 assert!(matches!(
995 incompatible.validate(),
996 Err(Error::UnsupportedFeature(_))
997 ));
998 assert!(InspectArchiveKey::from_pkcs8_pem("not-a-key").is_err());
999 }
1000}