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    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
636/// Atomically publish a verified archive without replacing an existing destination.
637pub 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}