Skip to main content

nvidia_sdk/
lib.rs

1//! Verified acquisition and runtime staging for pinned NVIDIA SDK releases.
2
3#![forbid(unsafe_code)]
4
5use {
6    anyhow::{Context as _, Result, bail, ensure},
7    fs2::FileExt as _,
8    serde::{Deserialize, Serialize},
9    sha2::{Digest as _, Sha256},
10    std::{
11        env,
12        ffi::OsStr,
13        fs::{self, File, OpenOptions},
14        io::{self, Read as _, Write as _},
15        path::{Component, Path, PathBuf},
16        str::FromStr,
17    },
18    zip::ZipArchive,
19};
20
21const LOCK_MANIFEST: &str = include_str!("../sdk-lock.toml");
22
23fn cache_dir() -> Result<PathBuf> {
24    if let Some(path) = env::var_os("NVIDIA_SDK_CACHE").filter(|value| !value.is_empty()) {
25        return Ok(PathBuf::from(path));
26    }
27
28    if cfg!(windows) {
29        return env::var_os("LOCALAPPDATA")
30            .map(PathBuf::from)
31            .map(|path| path.join("nvidia-sdk"))
32            .context("set NVIDIA_SDK_CACHE or LOCALAPPDATA");
33    }
34
35    if let Some(path) = env::var_os("XDG_CACHE_HOME").filter(|value| !value.is_empty()) {
36        return Ok(PathBuf::from(path).join("nvidia-sdk"));
37    }
38
39    env::var_os("HOME")
40        .map(PathBuf::from)
41        .map(|path| path.join(".cache/nvidia-sdk"))
42        .context("set NVIDIA_SDK_CACHE or HOME")
43}
44
45fn download(url: &str, destination: &Path) -> Result<()> {
46    let response = ureq::get(url)
47        .set(
48            "User-Agent",
49            concat!("nvidia-sdk/", env!("CARGO_PKG_VERSION")),
50        )
51        .call()
52        .with_context(|| format!("downloading {url}"))?;
53    let mut source = response.into_reader();
54    let mut destination_file = File::create(destination)
55        .with_context(|| format!("creating download file {}", destination.display()))?;
56    io::copy(&mut source, &mut destination_file)
57        .with_context(|| format!("downloading {url} to {}", destination.display()))?;
58    destination_file
59        .flush()
60        .with_context(|| format!("flushing download file {}", destination.display()))?;
61
62    Ok(())
63}
64
65fn extract_zip(archive_path: &Path, output: &Path) -> Result<()> {
66    fs::create_dir_all(output)
67        .with_context(|| format!("creating extraction directory {}", output.display()))?;
68    let file = File::open(archive_path)
69        .with_context(|| format!("opening archive {}", archive_path.display()))?;
70    let mut archive = ZipArchive::new(file)
71        .with_context(|| format!("reading zip archive {}", archive_path.display()))?;
72
73    for index in 0..archive.len() {
74        let mut entry = archive.by_index(index).with_context(|| {
75            format!(
76                "reading entry {index} in archive {}",
77                archive_path.display()
78            )
79        })?;
80        let relative = entry.enclosed_name().with_context(|| {
81            format!(
82                "unsafe path in entry {index} of archive {}",
83                archive_path.display()
84            )
85        })?;
86        safe_relative_path(&relative).with_context(|| {
87            format!(
88                "validating entry {index} in archive {}",
89                archive_path.display()
90            )
91        })?;
92        let destination = output.join(relative);
93
94        if entry.is_dir() {
95            fs::create_dir_all(&destination)
96                .with_context(|| format!("creating archive directory {}", destination.display()))?;
97
98            continue;
99        }
100
101        if let Some(parent) = destination.parent() {
102            fs::create_dir_all(parent)
103                .with_context(|| format!("creating extraction parent {}", parent.display()))?;
104        }
105
106        let mut file = File::create(&destination)
107            .with_context(|| format!("creating extracted file {}", destination.display()))?;
108        io::copy(&mut entry, &mut file).with_context(|| {
109            format!(
110                "extracting entry {index} from {} to {}",
111                archive_path.display(),
112                destination.display()
113            )
114        })?;
115    }
116
117    Ok(())
118}
119
120fn verify_hash(path: &Path, expected: &str) -> Result<()> {
121    let mut file = File::open(path)
122        .with_context(|| format!("opening archive for hashing {}", path.display()))?;
123    let mut hasher = Sha256::new();
124    io::copy(&mut file, &mut hasher)
125        .with_context(|| format!("hashing archive {}", path.display()))?;
126    let actual = format!("{:x}", hasher.finalize());
127
128    ensure!(
129        actual == expected,
130        "archive hash mismatch for {}: expected {expected}, got {actual}",
131        path.display()
132    );
133
134    Ok(())
135}
136
137fn remove_path(path: &Path) -> Result<()> {
138    match fs::symlink_metadata(path) {
139        Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => {
140            fs::remove_file(path).with_context(|| format!("removing file {}", path.display()))?;
141        }
142        Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path)
143            .with_context(|| format!("removing directory {}", path.display()))?,
144        Ok(_) => bail!("unsupported filesystem object at {}", path.display()),
145        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
146        Err(error) => {
147            return Err(error).with_context(|| format!("reading metadata for {}", path.display()));
148        }
149    }
150
151    Ok(())
152}
153
154fn safe_relative_path(path: &Path) -> Result<()> {
155    ensure!(!path.as_os_str().is_empty(), "empty relative path");
156
157    ensure!(
158        !path.is_absolute(),
159        "absolute path is not allowed: {}",
160        path.display()
161    );
162
163    ensure!(
164        path.components()
165            .all(|component| matches!(component, Component::Normal(_))),
166        "unsafe relative path: {}",
167        path.display()
168    );
169
170    Ok(())
171}
172
173#[derive(Debug, Deserialize, Serialize)]
174struct Artifact {
175    assembly: Option<SourceAssembly>,
176    id: String,
177    required: Vec<PathBuf>,
178    runtime: Option<Vec<RuntimeFile>>,
179    sha256: String,
180    targets: Vec<String>,
181    url: String,
182}
183
184impl Artifact {
185    fn acquire(&self, path: &Path, sdk: Sdk, entry: &SdkEntry) -> Result<()> {
186        ensure!(
187            self.url.starts_with("https://")
188                && self.sha256.len() == 64
189                && self.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()),
190            "invalid acquisition metadata for {} {}",
191            sdk.name(),
192            self.id
193        );
194
195        let parent = path
196            .parent()
197            .with_context(|| format!("sdk cache path has no parent: {}", path.display()))?;
198        fs::create_dir_all(parent)
199            .with_context(|| format!("creating cache directory {}", parent.display()))?;
200        let key = self.cache_key()?;
201        let lock_path = parent.join(format!("{key}.lock"));
202        let lock = OpenOptions::new()
203            .create(true)
204            .read(true)
205            .truncate(false)
206            .write(true)
207            .open(&lock_path)
208            .with_context(|| format!("opening cache lock {}", lock_path.display()))?;
209        lock.lock_exclusive()
210            .with_context(|| format!("locking cache file {}", lock_path.display()))?;
211
212        if self.validate_cached(path).is_ok() {
213            return Ok(());
214        }
215
216        eprintln!(
217            "acquiring nvidia {} sdk {} from {}\nlicense: {} ({})",
218            sdk.name(),
219            entry.version,
220            self.url,
221            entry.license,
222            entry.license_url
223        );
224
225        let temporary = parent.join(format!(".{key}.{}.tmp", std::process::id()));
226        remove_path(&temporary)?;
227        fs::create_dir_all(&temporary)
228            .with_context(|| format!("creating staging directory {}", temporary.display()))?;
229        let result = (|| -> Result<()> {
230            let archive_path = temporary.join("archive.zip");
231            download(&self.url, &archive_path)?;
232            verify_hash(&archive_path, &self.sha256)?;
233            let extracted = temporary.join("extracted");
234            extract_zip(&archive_path, &extracted)?;
235            let root = if let Some(assembly) = &self.assembly {
236                safe_relative_path(&assembly.root)?;
237                let root = extracted.join(&assembly.root);
238
239                ensure!(
240                    root.is_dir(),
241                    "missing source archive root {}",
242                    root.display()
243                );
244
245                assembly.prepare(&root, &temporary)?;
246
247                root
248            } else {
249                self.locate_root(&extracted)?
250            };
251            self.validate(&root)?;
252
253            if self.assembly.is_some() {
254                let stamp = root.join(".nvidia-sdk-source");
255                fs::write(&stamp, &key).with_context(|| {
256                    format!("writing source assembly stamp {}", stamp.display())
257                })?;
258            }
259
260            remove_path(path)?;
261            fs::rename(&root, path).with_context(|| {
262                format!(
263                    "installing downloaded sdk from {} to {}",
264                    root.display(),
265                    path.display()
266                )
267            })?;
268
269            Ok(())
270        })();
271        let cleanup = remove_path(&temporary);
272        result?;
273
274        cleanup
275    }
276
277    fn cache_key(&self) -> Result<String> {
278        // Source recipes, dependency pins, and patches are part of the cache identity.
279        if self.assembly.is_some() {
280            Ok(format!(
281                "{:x}",
282                Sha256::digest(serde_json::to_vec(self).with_context(|| format!(
283                    "serializing cache identity for artifact {}",
284                    self.id
285                ))?)
286            ))
287        } else {
288            Ok(self.sha256.clone())
289        }
290    }
291
292    fn validate_cached(&self, path: &Path) -> Result<()> {
293        self.validate(path)?;
294
295        if self.assembly.is_some() {
296            let stamp = path.join(".nvidia-sdk-source");
297
298            ensure!(
299                fs::read_to_string(&stamp).with_context(|| format!(
300                    "reading source assembly stamp {}",
301                    stamp.display()
302                ))? == self.cache_key()?,
303                "source sdk assembly is incomplete or stale: {}",
304                stamp.display()
305            );
306        }
307
308        Ok(())
309    }
310
311    fn locate_root(&self, extracted: &Path) -> Result<PathBuf> {
312        if self.validate(extracted).is_ok() {
313            return Ok(extracted.to_owned());
314        }
315
316        let mut candidates = fs::read_dir(extracted)
317            .with_context(|| format!("reading extracted directory {}", extracted.display()))?
318            .filter_map(Result::ok)
319            .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
320            .map(|entry| entry.path())
321            .filter(|path| self.validate(path).is_ok());
322        let root = candidates.next().with_context(|| {
323            format!(
324                "archive does not contain the expected sdk layout: {}",
325                extracted.display()
326            )
327        })?;
328
329        ensure!(
330            candidates.next().is_none(),
331            "archive contains multiple possible sdk roots: {}",
332            extracted.display()
333        );
334
335        Ok(root)
336    }
337
338    fn supports(&self, target: &str) -> bool {
339        self.targets
340            .iter()
341            .any(|candidate| candidate == "*" || candidate == target)
342    }
343
344    fn validate(&self, path: &Path) -> Result<()> {
345        ensure!(
346            path.is_dir(),
347            "sdk directory is missing: {}",
348            path.display()
349        );
350
351        for relative in &self.required {
352            safe_relative_path(relative)?;
353            let required = path.join(relative);
354            let metadata = fs::metadata(&required).with_context(|| {
355                format!(
356                    "reading metadata for required sdk file {}",
357                    required.display()
358                )
359            })?;
360
361            ensure!(
362                metadata.is_file(),
363                "sdk input is not a file: {}",
364                required.display()
365            );
366
367            ensure!(
368                metadata.len() > 0,
369                "sdk input is empty: {}",
370                required.display()
371            );
372
373            let mut prefix = [0_u8; 128];
374            let count = File::open(&required)
375                .with_context(|| format!("opening required sdk file {}", required.display()))?
376                .read(&mut prefix)
377                .with_context(|| format!("reading required sdk file {}", required.display()))?;
378
379            ensure!(
380                !prefix[..count].starts_with(b"version https://git-lfs.github.com/spec/v1"),
381                "sdk input is a git lfs pointer: {}",
382                required.display()
383            );
384        }
385
386        Ok(())
387    }
388}
389
390#[derive(Debug, Deserialize)]
391struct LockManifest {
392    schema: u32,
393    sdks: SdkEntries,
394}
395
396impl LockManifest {
397    fn load() -> Result<Self> {
398        let manifest: Self = toml::from_str(LOCK_MANIFEST).context("parsing sdk-lock.toml")?;
399
400        ensure!(manifest.schema == 1, "unsupported sdk lock schema");
401
402        Ok(manifest)
403    }
404}
405
406/// A validated SDK tree and its pinned provenance.
407#[derive(Clone, Debug, Serialize)]
408pub struct ResolvedSdk {
409    pub artifact: String,
410    pub license: String,
411    pub license_url: String,
412    pub path: PathBuf,
413    pub sdk: Sdk,
414    pub source_url: Option<String>,
415    pub target: String,
416    pub version: String,
417}
418
419impl ResolvedSdk {
420    fn new(
421        sdk: Sdk,
422        target: &str,
423        entry: &SdkEntry,
424        artifact: &Artifact,
425        path: PathBuf,
426        source_url: Option<String>,
427    ) -> Self {
428        Self {
429            artifact: artifact.id.clone(),
430            license: entry.license.clone(),
431            license_url: entry.license_url.clone(),
432            path,
433            sdk,
434            source_url,
435            target: target.to_owned(),
436            version: entry.version.clone(),
437        }
438    }
439
440    /// Copies this SDK's selected redistributable runtime set into `output`.
441    ///
442    /// # Errors
443    ///
444    /// Returns an error if the resolved artifact has no runtime manifest or a
445    /// required file cannot be copied safely.
446    pub fn stage_runtime(&self, output: &Path, profile: RuntimeProfile) -> Result<Vec<StagedFile>> {
447        let manifest = LockManifest::load()?;
448        let entry = manifest.sdks.get(self.sdk);
449        let artifact = entry
450            .artifacts
451            .iter()
452            .find(|artifact| artifact.id == self.artifact)
453            .context("resolved artifact is absent from the embedded lock manifest")?;
454        let runtime = artifact
455            .runtime
456            .as_deref()
457            .context("sdk artifact does not define a redistributable runtime")?;
458        fs::create_dir_all(output)
459            .with_context(|| format!("creating runtime directory {}", output.display()))?;
460
461        let mut staged = Vec::new();
462
463        for file in runtime.iter().filter(|file| {
464            file.profile
465                .as_deref()
466                .is_none_or(|candidate| candidate == profile.name())
467        }) {
468            safe_relative_path(&file.source)?;
469            safe_relative_path(&file.destination)?;
470            let source = self.path.join(&file.source);
471            let destination = output.join(&file.destination);
472
473            ensure!(
474                source.is_file(),
475                "missing runtime file {}",
476                source.display()
477            );
478
479            if let Some(parent) = destination.parent() {
480                fs::create_dir_all(parent)
481                    .with_context(|| format!("creating runtime parent {}", parent.display()))?;
482            }
483
484            fs::copy(&source, &destination).with_context(|| {
485                format!(
486                    "copying runtime file {} to {}",
487                    source.display(),
488                    destination.display()
489                )
490            })?;
491            staged.push(StagedFile {
492                path: destination,
493                source,
494            });
495        }
496
497        Ok(staged)
498    }
499}
500
501/// Resolver settings used by build scripts and packaging tools.
502#[derive(Clone, Debug)]
503pub struct ResolveOptions {
504    pub cache_dir: Option<PathBuf>,
505    pub offline: bool,
506    pub target: String,
507}
508
509impl ResolveOptions {
510    fn environment_flag(name: &str) -> bool {
511        env::var_os(name).is_some_and(|value| {
512            !value.is_empty() && value != OsStr::new("0") && value != OsStr::new("false")
513        })
514    }
515
516    /// Constructs options for a Cargo build script.
517    ///
518    /// # Errors
519    ///
520    /// Returns an error when Cargo did not provide the target triple.
521    pub fn for_cargo() -> Result<Self> {
522        let target = env::var("TARGET").context("cargo did not provide TARGET")?;
523
524        Ok(Self {
525            cache_dir: None,
526            offline: Self::environment_flag("NVIDIA_SDK_OFFLINE")
527                || Self::environment_flag("CARGO_NET_OFFLINE"),
528            target,
529        })
530    }
531
532    #[must_use]
533    pub fn for_target(target: impl Into<String>) -> Self {
534        Self {
535            cache_dir: None,
536            offline: Self::environment_flag("NVIDIA_SDK_OFFLINE")
537                || Self::environment_flag("CARGO_NET_OFFLINE"),
538            target: target.into(),
539        }
540    }
541}
542
543#[derive(Debug, Deserialize, Serialize)]
544struct RuntimeFile {
545    destination: PathBuf,
546    profile: Option<String>,
547    source: PathBuf,
548}
549
550/// Runtime flavor selected from an SDK archive.
551#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
552pub enum RuntimeProfile {
553    #[default]
554    Release,
555    Development,
556}
557
558impl RuntimeProfile {
559    const fn name(self) -> &'static str {
560        match self {
561            Self::Release => "release",
562            Self::Development => "development",
563        }
564    }
565}
566
567impl FromStr for RuntimeProfile {
568    type Err = anyhow::Error;
569
570    fn from_str(value: &str) -> Result<Self> {
571        match value {
572            "release" => Ok(Self::Release),
573            "development" => Ok(Self::Development),
574            _ => bail!("unknown runtime profile {value:?}"),
575        }
576    }
577}
578
579/// A supported NVIDIA SDK integration.
580#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
581#[serde(rename_all = "kebab-case")]
582pub enum Sdk {
583    Dlss,
584    Nrd,
585    Omm,
586    Sharc,
587    Streamline,
588}
589
590impl Sdk {
591    #[must_use]
592    pub const fn name(self) -> &'static str {
593        match self {
594            Self::Dlss => "dlss",
595            Self::Nrd => "nrd",
596            Self::Omm => "omm",
597            Self::Sharc => "sharc",
598            Self::Streamline => "streamline",
599        }
600    }
601
602    /// Resolves and validates this SDK for one target.
603    ///
604    /// # Errors
605    ///
606    /// Returns an error if the target is unsupported, a configured SDK is invalid,
607    /// or a required archive cannot be securely acquired and validated.
608    pub fn resolve(self, options: &ResolveOptions) -> Result<ResolvedSdk> {
609        let manifest = LockManifest::load()?;
610        let entry = manifest.sdks.get(self);
611        let artifact = entry
612            .artifacts
613            .iter()
614            .find(|artifact| artifact.supports(&options.target))
615            .with_context(|| {
616                format!(
617                    "{} {} has no pinned artifact for {}",
618                    self.name(),
619                    entry.version,
620                    options.target
621                )
622            })?;
623
624        if let Some(path) = env::var_os(&entry.environment).filter(|value| !value.is_empty()) {
625            let path = PathBuf::from(path);
626            artifact.validate(&path).with_context(|| {
627                format!(
628                    "{} points to an invalid {} {} sdk",
629                    entry.environment,
630                    self.name(),
631                    entry.version
632                )
633            })?;
634
635            return Ok(ResolvedSdk::new(
636                self,
637                &options.target,
638                entry,
639                artifact,
640                path,
641                None,
642            ));
643        }
644
645        if let Some(root) = env::var_os("NVIDIA_SDK_ROOT").filter(|value| !value.is_empty()) {
646            let path = PathBuf::from(root).join(self.name()).join(&entry.version);
647
648            if path.exists() {
649                artifact.validate(&path)?;
650
651                return Ok(ResolvedSdk::new(
652                    self,
653                    &options.target,
654                    entry,
655                    artifact,
656                    path,
657                    None,
658                ));
659            }
660        }
661
662        let default_cache;
663        let cache = if let Some(cache) = &options.cache_dir {
664            cache
665        } else {
666            default_cache = cache_dir()?;
667            &default_cache
668        };
669        let path = cache
670            .join(self.name())
671            .join(&entry.version)
672            .join(artifact.cache_key()?);
673
674        if artifact.validate_cached(&path).is_ok() {
675            return Ok(ResolvedSdk::new(
676                self,
677                &options.target,
678                entry,
679                artifact,
680                path,
681                Some(artifact.url.clone()),
682            ));
683        }
684
685        ensure!(
686            !options.offline,
687            "{} {} is not cached and downloads are disabled",
688            self.name(),
689            entry.version
690        );
691
692        artifact.acquire(&path, self, entry)?;
693
694        Ok(ResolvedSdk::new(
695            self,
696            &options.target,
697            entry,
698            artifact,
699            path,
700            Some(artifact.url.clone()),
701        ))
702    }
703}
704
705impl FromStr for Sdk {
706    type Err = anyhow::Error;
707
708    fn from_str(value: &str) -> Result<Self> {
709        match value {
710            "dlss" => Ok(Self::Dlss),
711            "nrd" => Ok(Self::Nrd),
712            "omm" => Ok(Self::Omm),
713            "sharc" => Ok(Self::Sharc),
714            "streamline" => Ok(Self::Streamline),
715            _ => bail!("unknown nvidia sdk {value:?}"),
716        }
717    }
718}
719
720#[derive(Debug, Deserialize)]
721struct SdkEntries {
722    dlss: SdkEntry,
723    nrd: SdkEntry,
724    omm: SdkEntry,
725    sharc: SdkEntry,
726    streamline: SdkEntry,
727}
728
729impl SdkEntries {
730    fn get(&self, sdk: Sdk) -> &SdkEntry {
731        match sdk {
732            Sdk::Dlss => &self.dlss,
733            Sdk::Nrd => &self.nrd,
734            Sdk::Omm => &self.omm,
735            Sdk::Sharc => &self.sharc,
736            Sdk::Streamline => &self.streamline,
737        }
738    }
739}
740
741#[derive(Debug, Deserialize)]
742struct SdkEntry {
743    artifacts: Vec<Artifact>,
744    environment: String,
745    license: String,
746    license_url: String,
747    version: String,
748}
749
750#[derive(Debug, Deserialize, Serialize)]
751struct SourceAssembly {
752    dependencies: Vec<SourceDependency>,
753    #[serde(default)]
754    patches: Vec<SourcePatch>,
755    #[serde(default)]
756    relocations: Vec<SourceRelocation>,
757    root: PathBuf,
758}
759
760impl SourceAssembly {
761    fn prepare(&self, root: &Path, temporary: &Path) -> Result<()> {
762        for relocation in &self.relocations {
763            relocation.apply(root, root)?;
764        }
765
766        for (index, dependency) in self.dependencies.iter().enumerate() {
767            ensure!(
768                dependency.url.starts_with("https://")
769                    && dependency.sha256.len() == 64
770                    && dependency
771                        .sha256
772                        .bytes()
773                        .all(|byte| byte.is_ascii_hexdigit()),
774                "invalid source dependency acquisition metadata"
775            );
776
777            let archive = temporary.join(format!("dependency-{index}.zip"));
778            download(&dependency.url, &archive)?;
779            verify_hash(&archive, &dependency.sha256)?;
780            let extracted = temporary.join(format!("dependency-{index}"));
781            extract_zip(&archive, &extracted)?;
782
783            if let Some(files) = &dependency.files {
784                safe_relative_path(&dependency.root)?;
785                safe_relative_path(&dependency.destination)?;
786                let source = extracted.join(&dependency.root);
787                let destination = root.join(&dependency.destination);
788
789                ensure!(
790                    !destination.exists(),
791                    "source dependency destination already exists: {}",
792                    destination.display()
793                );
794
795                ensure!(!files.is_empty(), "empty source dependency file selection");
796
797                for relative in files {
798                    safe_relative_path(relative)?;
799                    let output = destination.join(relative);
800                    let parent = output.parent().with_context(|| {
801                        format!("dependency path has no parent: {}", output.display())
802                    })?;
803                    fs::create_dir_all(parent).with_context(|| {
804                        format!("creating dependency directory {}", parent.display())
805                    })?;
806                    let input = source.join(relative);
807                    fs::copy(&input, &output).with_context(|| {
808                        format!(
809                            "copying source dependency {} to {}",
810                            input.display(),
811                            output.display()
812                        )
813                    })?;
814                }
815            } else {
816                SourceRelocation {
817                    source: dependency.root.clone(),
818                    destination: dependency.destination.clone(),
819                }
820                .apply(&extracted, root)?;
821            }
822
823            remove_path(&extracted)?;
824            fs::remove_file(&archive)
825                .with_context(|| format!("removing dependency archive {}", archive.display()))?;
826        }
827
828        for patch in &self.patches {
829            patch.apply(root)?;
830        }
831
832        Ok(())
833    }
834}
835
836#[derive(Debug, Deserialize, Serialize)]
837struct SourceDependency {
838    destination: PathBuf,
839    #[serde(skip_serializing_if = "Option::is_none")]
840    files: Option<Vec<PathBuf>>,
841    root: PathBuf,
842    sha256: String,
843    url: String,
844}
845
846#[derive(Debug, Deserialize, Serialize)]
847struct SourcePatch {
848    after: String,
849    before: String,
850    path: PathBuf,
851}
852
853impl SourcePatch {
854    fn apply(&self, root: &Path) -> Result<()> {
855        safe_relative_path(&self.path)?;
856        let path = root.join(&self.path);
857        let source = fs::read_to_string(&path)
858            .with_context(|| format!("reading source patch input {}", path.display()))?;
859
860        ensure!(
861            !self.before.is_empty() && source.matches(&self.before).count() == 1,
862            "source patch preimage does not match exactly once: {}",
863            path.display()
864        );
865
866        fs::write(&path, source.replacen(&self.before, &self.after, 1))
867            .with_context(|| format!("writing patched source {}", path.display()))?;
868
869        Ok(())
870    }
871}
872
873#[derive(Debug, Deserialize, Serialize)]
874struct SourceRelocation {
875    destination: PathBuf,
876    source: PathBuf,
877}
878
879impl SourceRelocation {
880    fn apply(&self, source_root: &Path, destination_root: &Path) -> Result<()> {
881        safe_relative_path(&self.source)?;
882        safe_relative_path(&self.destination)?;
883        let source = source_root.join(&self.source);
884        let destination = destination_root.join(&self.destination);
885
886        ensure!(
887            !destination.exists(),
888            "source assembly destination already exists: {}",
889            destination.display()
890        );
891
892        let parent = destination
893            .parent()
894            .with_context(|| format!("assembly path has no parent: {}", destination.display()))?;
895        fs::create_dir_all(parent)
896            .with_context(|| format!("creating assembly directory {}", parent.display()))?;
897        fs::rename(&source, &destination).with_context(|| {
898            format!(
899                "assembling {} into {}",
900                source.display(),
901                destination.display()
902            )
903        })?;
904
905        Ok(())
906    }
907}
908
909/// A file copied into an application's runtime directory.
910#[derive(Clone, Debug, Serialize)]
911pub struct StagedFile {
912    pub path: PathBuf,
913    pub source: PathBuf,
914}
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919
920    #[test]
921    fn parses_lock_manifest() {
922        let manifest = LockManifest::load().unwrap();
923
924        assert_eq!(manifest.sdks.dlss.version, "310.4.0");
925        assert_eq!(manifest.sdks.streamline.artifacts.len(), 1);
926    }
927
928    #[test]
929    fn rejects_unsafe_paths() {
930        assert!(safe_relative_path(Path::new("include/header.h")).is_ok());
931        assert!(safe_relative_path(Path::new("../header.h")).is_err());
932        assert!(safe_relative_path(Path::new("/tmp/header.h")).is_err());
933    }
934
935    #[test]
936    fn shader_artifact_supports_all_targets_but_native_artifacts_remain_exact() {
937        let manifest = LockManifest::load().unwrap();
938
939        assert_eq!("sharc".parse::<Sdk>().unwrap(), Sdk::Sharc);
940        assert_eq!(Sdk::Sharc.name(), "sharc");
941        assert_eq!(serde_json::to_string(&Sdk::Sharc).unwrap(), "\"sharc\"");
942        assert_eq!(manifest.sdks.sharc.version, "1.8.3");
943        assert_eq!(manifest.sdks.sharc.environment, "SHARC_SDK");
944
945        let artifact = &manifest.sdks.sharc.artifacts[0];
946
947        assert!(artifact.assembly.is_none());
948        assert!(artifact.runtime.is_none());
949
950        for target in [
951            "x86_64-unknown-linux-gnu",
952            "x86_64-pc-windows-msvc",
953            "aarch64-apple-darwin",
954            "x86_64-apple-darwin",
955            "wasm32-unknown-unknown",
956            "future-target",
957        ] {
958            assert!(artifact.supports(target));
959
960            for sdk in [Sdk::Dlss, Sdk::Nrd, Sdk::Omm, Sdk::Streamline] {
961                for native in &manifest.sdks.get(sdk).artifacts {
962                    assert!(!native.targets.iter().any(|target| target.contains('*')));
963                    assert_eq!(
964                        native.supports(target),
965                        native.targets.iter().any(|candidate| candidate == target)
966                    );
967                }
968            }
969        }
970    }
971
972    #[test]
973    fn sharc_requires_every_header_and_license_at_the_official_root() {
974        let manifest = LockManifest::load().unwrap();
975        let artifact = &manifest.sdks.sharc.artifacts[0];
976        let temporary = tempfile::tempdir().unwrap();
977        let root = temporary.path().join("SHARC-commit");
978        fs::create_dir_all(root.join("include")).unwrap();
979
980        assert_eq!(artifact.required.len(), 6);
981
982        for relative in &artifact.required {
983            fs::write(root.join(relative), "fixture").unwrap();
984        }
985
986        assert_eq!(artifact.locate_root(temporary.path()).unwrap(), root);
987
988        artifact.validate_cached(&root).unwrap();
989
990        for relative in &artifact.required {
991            let path = root.join(relative);
992            fs::remove_file(&path).unwrap();
993
994            assert!(artifact.validate(&root).is_err());
995
996            fs::write(&path, "").unwrap();
997
998            assert!(artifact.validate(&root).is_err());
999
1000            fs::write(&path, "version https://git-lfs.github.com/spec/v1\n").unwrap();
1001
1002            assert!(artifact.validate(&root).is_err());
1003
1004            fs::write(&path, "fixture").unwrap();
1005        }
1006
1007        assert!(artifact.validate(&root.join("include")).is_err());
1008    }
1009
1010    #[test]
1011    fn source_artifacts_pin_the_complete_native_dependency_closure() {
1012        let manifest = LockManifest::load().unwrap();
1013
1014        for (sdk, targets) in [
1015            (
1016                Sdk::Nrd,
1017                &[
1018                    "x86_64-unknown-linux-gnu",
1019                    "x86_64-pc-windows-msvc",
1020                    "aarch64-apple-darwin",
1021                ][..],
1022            ),
1023            (
1024                Sdk::Omm,
1025                &["x86_64-apple-darwin", "aarch64-apple-darwin"][..],
1026            ),
1027        ] {
1028            for target in targets {
1029                let artifact = manifest
1030                    .sdks
1031                    .get(sdk)
1032                    .artifacts
1033                    .iter()
1034                    .find(|artifact| artifact.supports(target))
1035                    .unwrap();
1036                let assembly = artifact.assembly.as_ref().unwrap();
1037
1038                assert_eq!(
1039                    assembly.dependencies.len(),
1040                    if sdk == Sdk::Nrd { 6 } else { 4 }
1041                );
1042                assert!(artifact.url.starts_with("https://codeload.github.com/"));
1043                assert_eq!(artifact.sha256.len(), 64);
1044
1045                for dependency in &assembly.dependencies {
1046                    assert!(dependency.url.starts_with("https://codeload.github.com/"));
1047                    assert_eq!(dependency.sha256.len(), 64);
1048                    assert!(
1049                        dependency
1050                            .sha256
1051                            .bytes()
1052                            .all(|byte| byte.is_ascii_hexdigit())
1053                    );
1054
1055                    safe_relative_path(&dependency.root).unwrap();
1056                    safe_relative_path(&dependency.destination).unwrap();
1057
1058                    if let Some(files) = &dependency.files {
1059                        assert!(!files.is_empty());
1060
1061                        for file in files {
1062                            safe_relative_path(file).unwrap();
1063                        }
1064                    }
1065                }
1066
1067                assert!(artifact.required.len() > 10);
1068            }
1069        }
1070    }
1071
1072    #[test]
1073    fn all_archive_hashes_are_valid_including_streamline() {
1074        let manifest = LockManifest::load().unwrap();
1075
1076        for sdk in [Sdk::Dlss, Sdk::Nrd, Sdk::Omm, Sdk::Sharc, Sdk::Streamline] {
1077            for artifact in &manifest.sdks.get(sdk).artifacts {
1078                assert_eq!(artifact.sha256.len(), 64);
1079                assert!(artifact.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()));
1080            }
1081        }
1082
1083        assert_eq!(
1084            manifest.sdks.streamline.artifacts[0].sha256,
1085            "b7e4f31706cfacafba95d2d4abc2c9dcf2dc5fc58b2a6917c71f83051d271aa1"
1086        );
1087    }
1088
1089    #[test]
1090    fn source_recipe_changes_invalidate_cache_identity() {
1091        let mut artifact = LockManifest::load().unwrap().sdks.omm.artifacts.remove(2);
1092        let original = artifact.cache_key().unwrap();
1093        artifact.assembly.as_mut().unwrap().dependencies[0].sha256 = "0".repeat(64);
1094        let dependency_changed = artifact.cache_key().unwrap();
1095
1096        assert_ne!(original, dependency_changed);
1097
1098        artifact.assembly.as_mut().unwrap().patches[0]
1099            .after
1100            .push('\n');
1101
1102        assert_ne!(dependency_changed, artifact.cache_key().unwrap());
1103    }
1104
1105    #[test]
1106    fn assembles_and_patches_only_staging_then_requires_completion_stamp() {
1107        let temporary = tempfile::tempdir().unwrap();
1108        let override_root = temporary.path().join("override");
1109        let staging = temporary.path().join("staging");
1110        fs::create_dir_all(&override_root).unwrap();
1111        fs::create_dir_all(staging.join("upstream")).unwrap();
1112        fs::write(override_root.join("header.h"), "#elif __linux__\n").unwrap();
1113        fs::copy(
1114            override_root.join("header.h"),
1115            staging.join("upstream/header.h"),
1116        )
1117        .unwrap();
1118        let artifact: Artifact = toml::from_str(
1119            r##"
1120id = "fixture"
1121url = "https://example.invalid/fixture.zip"
1122sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1123targets = ["fixture"]
1124required = ["header.h"]
1125[assembly]
1126root = "fixture"
1127dependencies = []
1128[[assembly.relocations]]
1129source = "upstream/header.h"
1130destination = "header.h"
1131[[assembly.patches]]
1132path = "header.h"
1133before = "#elif __linux__"
1134after = "#elif defined(__linux__) || defined(__APPLE__)"
1135"##,
1136        )
1137        .unwrap();
1138        // Overrides need neither a recipe stamp nor the acquired-source patches.
1139        artifact.validate(&override_root).unwrap();
1140        artifact
1141            .assembly
1142            .as_ref()
1143            .unwrap()
1144            .prepare(&staging, temporary.path())
1145            .unwrap();
1146
1147        assert_eq!(
1148            fs::read_to_string(override_root.join("header.h")).unwrap(),
1149            "#elif __linux__\n"
1150        );
1151        assert!(
1152            fs::read_to_string(staging.join("header.h"))
1153                .unwrap()
1154                .contains("__APPLE__")
1155        );
1156        assert!(artifact.validate_cached(&staging).is_err());
1157
1158        fs::write(
1159            staging.join(".nvidia-sdk-source"),
1160            artifact.cache_key().unwrap(),
1161        )
1162        .unwrap();
1163        artifact.validate_cached(&staging).unwrap();
1164        fs::write(staging.join(".nvidia-sdk-source"), "stale").unwrap();
1165
1166        assert!(artifact.validate_cached(&staging).is_err());
1167    }
1168
1169    #[test]
1170    fn rejects_patch_drift_unsafe_relocations_and_wrong_hashes() {
1171        let temporary = tempfile::tempdir().unwrap();
1172        let path = temporary.path().join("header.h");
1173        fs::write(&path, "abc").unwrap();
1174        verify_hash(
1175            &path,
1176            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
1177        )
1178        .unwrap();
1179        let error = verify_hash(&path, &"0".repeat(64)).unwrap_err().to_string();
1180
1181        assert!(error.contains("archive hash mismatch"));
1182        assert!(error.contains(path.to_str().unwrap()));
1183
1184        let source_patch = SourcePatch {
1185            path: "header.h".into(),
1186            before: "missing".into(),
1187            after: "patched".into(),
1188        };
1189
1190        assert!(source_patch.apply(temporary.path()).is_err());
1191
1192        fs::write(&path, "missing missing").unwrap();
1193
1194        assert!(source_patch.apply(temporary.path()).is_err());
1195
1196        let relocation = SourceRelocation {
1197            source: "header.h".into(),
1198            destination: "../escaped".into(),
1199        };
1200
1201        assert!(
1202            relocation
1203                .apply(temporary.path(), temporary.path())
1204                .is_err()
1205        );
1206
1207        let relocation = SourceRelocation {
1208            source: "../escaped".into(),
1209            destination: "header.h".into(),
1210        };
1211
1212        assert!(
1213            relocation
1214                .apply(temporary.path(), temporary.path())
1215                .is_err()
1216        );
1217    }
1218
1219    #[test]
1220    fn file_and_zip_errors_name_operation_and_preserve_path_case() {
1221        let temporary = tempfile::tempdir().unwrap();
1222        let path = temporary.path().join("MixedCase.zip");
1223        let output = temporary.path().join("extracted");
1224        let error = verify_hash(&path, &"0".repeat(64)).unwrap_err().to_string();
1225
1226        assert!(error.contains("opening archive for hashing"));
1227        assert!(error.contains(path.to_str().unwrap()));
1228
1229        let error = extract_zip(&path, &output).unwrap_err().to_string();
1230
1231        assert!(error.contains("opening archive"));
1232        assert!(error.contains(path.to_str().unwrap()));
1233
1234        fs::write(&path, "not a zip archive").unwrap();
1235        let error = extract_zip(&path, &output).unwrap_err().to_string();
1236
1237        assert!(error.contains("reading zip archive"));
1238        assert!(error.contains(path.to_str().unwrap()));
1239    }
1240}