Skip to main content

rc_core/admin/
inspect_archive.rs

1//! Encrypted RustFS diagnostic archive contracts and local verification.
2
3use 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/// Exact capability fields required before invoking the archive route.
42#[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    /// Validate the server advertisement against the only contract understood by this client.
59    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
105/// Opaque RSA private key used only for local archive decryption.
106pub struct InspectArchiveKey {
107    private_key: RsaPrivateKey,
108    public_key_pem: String,
109}
110
111impl InspectArchiveKey {
112    /// Generate a fresh 2048-bit ephemeral key.
113    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    /// Parse caller-provided PKCS#8 PEM private-key material.
120    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
152/// Sanitized transport request. It intentionally has no `Debug` implementation.
153pub 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
161/// Encrypted response staged in private temporary storage.
162pub struct EncryptedInspectArchive {
163    pub file: NamedTempFile,
164    pub bytes: u64,
165}
166
167/// Transport boundary implemented by the RustFS Admin API adapter.
168#[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
191/// Fully authenticated and structurally validated plaintext archive.
192pub 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/// Safe metadata returned after atomic publication.
201#[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/// Cooperative cancellation shared with blocking decryption and validation.
212#[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
243/// Decrypt, authenticate, and validate an encrypted archive without publishing plaintext.
244pub 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
565/// Reject output directories containing symbolic links or parent traversal.
566pub 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(&current).map_err(|_| {
589                    Error::InvalidPath(
590                        "Diagnostic archive output directory does not exist or is inaccessible"
591                            .to_string(),
592                    )
593                })?;
594                // macOS exposes system roots such as /var and /tmp as root-level aliases.
595                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
623/// Atomically publish a verified archive without replacing an existing destination.
624pub 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}