Skip to main content

alpha2_native/openh264/
library.rs

1//! Finding, verifying and loading the `OpenH264` library.
2//!
3//! alpha2 ships no H.264 encoder. The user downloads Cisco's binary on request
4//! (`npx @alpha2-sh/node install --openh264`, `python -m alpha2 install --openh264`) into the
5//! shared host cache; nothing downloads it implicitly.
6//!
7//! Lookup order:
8//! 1. `ALPHA2_OPENH264_LIBRARY`, a path whose SHA-256 must be Cisco's unless
9//!    `ALPHA2_OPENH264_UNVERIFIED=1`. A set path that fails is an error, not a fallthrough.
10//! 2. The shared cache, `<cache>/openh264-<version>-<target>/<library>`, hash-verified.
11//! 3. A system package (provider `system`): `libopenh264.so.8` via the Linux loader, or on macOS
12//!    only absolute Homebrew/`MacPorts` paths ([`MACOS_SYSTEM_CANDIDATES`]), since `dlopen` of a
13//!    leaf name there also searches the working directory. None on Windows.
14//!
15//! An unverified library must still report a supported API version. `ALPHA2_OPENH264=0` disables it
16//! all, as Cisco's licence requires.
17
18use std::fmt::Write as _;
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, OnceLock};
21
22use sha2::{Digest, Sha256};
23
24use super::ffi;
25
26/// The `OpenH264` release `release/openh264/manifest.toml` pins (`version`).
27pub const OPENH264_VERSION: &str = "2.7.0";
28
29/// The text Cisco's licence requires wherever the user controls the codec (manifest `notice`).
30pub const OPENH264_NOTICE: &str = "OpenH264 Video Codec provided by Cisco Systems, Inc.";
31
32/// What a user runs to install Cisco's binary.
33pub const OPENH264_INSTALL_COMMAND: &str =
34    "python -m alpha2 install --openh264, or npx @alpha2-sh/node install --openh264";
35
36/// API versions (major, minor) the FFI is written against; v2.6.0 and v2.7.0 headers are identical
37/// and both are the `.8` ABI.
38const COMPATIBLE_API: &[(u32, u32)] = &[(2, 6), (2, 7)];
39
40/// This target's manifest entry: cached library name, SHA-256, and system loader names.
41struct PinnedLibrary {
42    target: &'static str,
43    library: &'static str,
44    sha256: &'static str,
45    system_candidates: &'static [&'static str],
46}
47
48/// Where macOS packages put the `.8` library (Homebrew on Apple silicon and Intel,
49/// `MacPorts`). Absolute paths only.
50#[cfg_attr(
51    not(all(target_os = "macos", target_arch = "aarch64")),
52    allow(dead_code)
53)]
54pub(crate) const MACOS_SYSTEM_CANDIDATES: &[&str] = &[
55    "/opt/homebrew/lib/libopenh264.8.dylib",
56    "/usr/local/lib/libopenh264.8.dylib",
57    "/opt/local/lib/libopenh264.8.dylib",
58];
59
60#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
61const PINNED: Option<PinnedLibrary> = Some(PinnedLibrary {
62    target: "linux-x64",
63    library: "libopenh264-2.7.0-linux64.8.so",
64    sha256: "1522f873a482923c5ec018a4415e1866898197528828c50f90fd300c94620df3",
65    system_candidates: &["libopenh264.so.8"],
66});
67#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
68const PINNED: Option<PinnedLibrary> = Some(PinnedLibrary {
69    target: "linux-arm64",
70    library: "libopenh264-2.7.0-linux-arm64.8.so",
71    sha256: "9ac664b0154fd0868491c2a904397b8a05779874c0603b8b1f16ad15de1ffd82",
72    system_candidates: &["libopenh264.so.8"],
73});
74#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
75const PINNED: Option<PinnedLibrary> = Some(PinnedLibrary {
76    target: "macos-arm64",
77    library: "libopenh264-2.7.0-mac-arm64.dylib",
78    sha256: "2e1ae3cd10c08328ca03a1f304fd2a1a2a7e5658c6d5a8582101a963f7995ef0",
79    system_candidates: MACOS_SYSTEM_CANDIDATES,
80});
81#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
82const PINNED: Option<PinnedLibrary> = Some(PinnedLibrary {
83    target: "win-x64",
84    library: "openh264-2.7.0-win64.dll",
85    sha256: "0ef5e7f3b38753b15c13afa0418d4d7805fa228e6adfd3e425bbf758cff1c288",
86    system_candidates: &[],
87});
88#[cfg(not(any(
89    all(
90        target_os = "linux",
91        any(target_arch = "x86_64", target_arch = "aarch64")
92    ),
93    all(target_os = "macos", target_arch = "aarch64"),
94    all(target_os = "windows", target_arch = "x86_64"),
95)))]
96const PINNED: Option<PinnedLibrary> = None;
97
98/// Who provided the library that loaded.
99#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
100#[non_exhaustive]
101pub enum OpenH264Provider {
102    /// Cisco's binary, verified by its SHA-256.
103    Cisco,
104    /// A library the system loader found, or one named with verification off; version-checked only.
105    System,
106}
107
108impl OpenH264Provider {
109    /// Stable identifier for `capabilities` and the export result.
110    #[must_use]
111    pub const fn as_str(self) -> &'static str {
112        match self {
113            Self::Cisco => "cisco-openh264",
114            Self::System => "system",
115        }
116    }
117}
118
119/// An `OpenH264` library that loaded and passed its checks.
120pub struct OpenH264Library {
121    pub(crate) api: Arc<ffi::Api>,
122    provider: OpenH264Provider,
123    version: String,
124    path: String,
125}
126
127impl std::fmt::Debug for OpenH264Library {
128    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        formatter
130            .debug_struct("OpenH264Library")
131            .field("provider", &self.provider)
132            .field("version", &self.version)
133            .field("path", &self.path)
134            .finish_non_exhaustive()
135    }
136}
137
138impl OpenH264Library {
139    /// Who provided it.
140    #[must_use]
141    pub const fn provider(&self) -> OpenH264Provider {
142        self.provider
143    }
144
145    /// The version it reports, `major.minor.revision`.
146    #[must_use]
147    pub fn version(&self) -> &str {
148        &self.version
149    }
150
151    /// Where it was loaded from: a path, or the name given to the system loader.
152    #[must_use]
153    pub fn path(&self) -> &str {
154        &self.path
155    }
156}
157
158/// Whether `OpenH264` can encode in this process, and if not, why.
159#[derive(Clone, Debug)]
160#[non_exhaustive]
161pub enum OpenH264Status {
162    /// Loaded and checked.
163    Available(Arc<OpenH264Library>),
164    /// `ALPHA2_OPENH264=0`.
165    Disabled,
166    /// No library anywhere the host looks; the string says where it looked.
167    NotInstalled(String),
168    /// A library was found and refused: the reason names it and why.
169    Unusable(String),
170}
171
172impl OpenH264Status {
173    /// Stable identifier for `capabilities`.
174    #[must_use]
175    pub const fn as_str(&self) -> &'static str {
176        match self {
177            Self::Available(_) => "available",
178            Self::Disabled => "disabled",
179            Self::NotInstalled(_) => "not-installed",
180            Self::Unusable(_) => "unusable",
181        }
182    }
183
184    /// The library, when one loaded.
185    #[must_use]
186    pub fn library(&self) -> Option<&Arc<OpenH264Library>> {
187        match self {
188            Self::Available(library) => Some(library),
189            _ => None,
190        }
191    }
192
193    /// Why it cannot encode, or `None` when it can.
194    #[must_use]
195    pub fn reason(&self) -> Option<String> {
196        match self {
197            Self::Available(_) => None,
198            Self::Disabled => Some("ALPHA2_OPENH264=0 turned OpenH264 off".to_owned()),
199            Self::NotInstalled(reason) | Self::Unusable(reason) => Some(reason.clone()),
200        }
201    }
202
203    /// What the user can do about it, or `None` when it can encode or the user disabled it.
204    #[must_use]
205    pub fn remedy(&self) -> Option<String> {
206        match self {
207            Self::Available(_) | Self::Disabled => None,
208            Self::NotInstalled(_) | Self::Unusable(_) if PINNED.is_none() => None,
209            Self::NotInstalled(_) => Some(format!(
210                "install Cisco's OpenH264 binary: {OPENH264_INSTALL_COMMAND}"
211            )),
212            Self::Unusable(_) => Some(format!(
213                "reinstall Cisco's OpenH264 binary with --force ({OPENH264_INSTALL_COMMAND} \
214                 --force), or fix ALPHA2_OPENH264_LIBRARY"
215            )),
216        }
217    }
218}
219
220/// `OpenH264` for this process, looked up once on first use.
221#[must_use]
222pub fn openh264_status() -> &'static OpenH264Status {
223    static STATUS: OnceLock<OpenH264Status> = OnceLock::new();
224    STATUS.get_or_init(|| {
225        let status = resolve(&Lookup::from_environment());
226        if let OpenH264Status::Unusable(reason) = &status {
227            alpha2_os::diagnostic!("alpha2 native: OpenH264 is not used: {reason}");
228        }
229        status
230    })
231}
232
233/// Everything a lookup reads from the environment, so tests can supply their own.
234pub(crate) struct Lookup {
235    /// `ALPHA2_OPENH264`.
236    pub(crate) enabled: Option<String>,
237    /// `ALPHA2_OPENH264_LIBRARY`.
238    pub(crate) library: Option<PathBuf>,
239    /// `ALPHA2_OPENH264_UNVERIFIED`.
240    pub(crate) unverified: bool,
241    /// The host cache directory, when one can be named.
242    pub(crate) cache: Option<PathBuf>,
243    /// System libraries to try in order: leaf names go to the loader, absolute paths
244    /// load only if they are files.
245    pub(crate) system: &'static [&'static str],
246}
247
248/// This target's system libraries ([`PinnedLibrary::system_candidates`]).
249pub(crate) fn system_candidates() -> &'static [&'static str] {
250    PINNED
251        .as_ref()
252        .map_or(&[], |pinned| pinned.system_candidates)
253}
254
255impl Lookup {
256    fn from_environment() -> Self {
257        let set = |name: &str| std::env::var_os(name).filter(|value| !value.is_empty());
258        Self {
259            enabled: set("ALPHA2_OPENH264").map(|value| value.to_string_lossy().into_owned()),
260            library: set("ALPHA2_OPENH264_LIBRARY").map(PathBuf::from),
261            unverified: set("ALPHA2_OPENH264_UNVERIFIED").is_some_and(|value| value == "1"),
262            cache: host_cache_directory(),
263            system: system_candidates(),
264        }
265    }
266}
267
268/// The host archive and `OpenH264` install directory, as the Node and Python clients name it:
269/// `ALPHA2_HOST_CACHE_DIR`, else `alpha2` under `$XDG_CACHE_HOME` (when absolute) or `~/.cache` on
270/// Linux, `~/Library/Caches` on macOS, `%LOCALAPPDATA%` on Windows.
271pub(crate) fn host_cache_directory() -> Option<PathBuf> {
272    let variable = |name: &str| {
273        std::env::var_os(name)
274            .filter(|value| !value.to_string_lossy().trim().is_empty())
275            .map(PathBuf::from)
276    };
277    if let Some(cache) = variable("ALPHA2_HOST_CACHE_DIR") {
278        return Some(if cache.is_absolute() {
279            cache
280        } else {
281            std::env::current_dir().ok()?.join(cache)
282        });
283    }
284    if cfg!(target_os = "windows") {
285        let base = variable("LOCALAPPDATA")
286            .or_else(|| variable("USERPROFILE").map(|home| home.join("AppData").join("Local")))?;
287        return Some(base.join("alpha2"));
288    }
289    let home = variable("HOME");
290    if cfg!(target_os = "macos") {
291        return Some(home?.join("Library").join("Caches").join("alpha2"));
292    }
293    let base = variable("XDG_CACHE_HOME")
294        .filter(|path| path.is_absolute())
295        .or_else(|| home.map(|home| home.join(".cache")))?;
296    Some(base.join("alpha2"))
297}
298
299/// Where the install puts this target's library under `cache`.
300pub(crate) fn cached_library_path(cache: &Path) -> Option<PathBuf> {
301    let pinned = PINNED.as_ref()?;
302    Some(
303        cache
304            .join(format!("openh264-{OPENH264_VERSION}-{}", pinned.target))
305            .join(pinned.library),
306    )
307}
308
309/// Looks for `OpenH264` as [the module](self) describes.
310pub(crate) fn resolve(lookup: &Lookup) -> OpenH264Status {
311    if lookup.enabled.as_deref() == Some("0") {
312        return OpenH264Status::Disabled;
313    }
314    if let Some(path) = &lookup.library {
315        return if lookup.unverified {
316            load(path, OpenH264Provider::System, None)
317        } else {
318            match PINNED.as_ref() {
319                Some(pinned) => load(path, OpenH264Provider::Cisco, Some(pinned.sha256)),
320                None => OpenH264Status::Unusable(format!(
321                    "ALPHA2_OPENH264_LIBRARY={}: Cisco publishes no binary alpha2 knows the \
322                     SHA-256 of for this platform; set ALPHA2_OPENH264_UNVERIFIED=1 to load it \
323                     anyway",
324                    path.display()
325                )),
326            }
327        };
328    }
329    let mut looked = Vec::new();
330    if let (Some(cache), Some(pinned)) = (&lookup.cache, PINNED.as_ref())
331        && let Some(path) = cached_library_path(cache)
332    {
333        if path.is_file() {
334            return load(&path, OpenH264Provider::Cisco, Some(pinned.sha256));
335        }
336        looked.push(path.display().to_string());
337    }
338    for name in lookup.system {
339        let path = Path::new(name);
340        let shown = if path.is_absolute() {
341            (*name).to_owned()
342        } else {
343            format!("{name} on the loader path")
344        };
345        if path.is_absolute() && !path.is_file() {
346            looked.push(shown);
347            continue;
348        }
349        match ffi::Api::load(path) {
350            Ok(api) => return checked(api, OpenH264Provider::System, name),
351            Err(ffi::LoadError::Open(_)) => looked.push(shown),
352            Err(error) => return OpenH264Status::Unusable(refusal(name, &error)),
353        }
354    }
355    OpenH264Status::NotInstalled(if PINNED.is_none() {
356        "Cisco publishes no OpenH264 binary for this platform".to_owned()
357    } else if looked.is_empty() {
358        "no OpenH264 library was found".to_owned()
359    } else {
360        format!("no OpenH264 library at {}", looked.join(" or "))
361    })
362}
363
364/// Loads `path` after checking its SHA-256 against `sha256`, when given.
365fn load(path: &Path, provider: OpenH264Provider, sha256: Option<&str>) -> OpenH264Status {
366    let shown = path.display().to_string();
367    if let Some(expected) = sha256 {
368        let actual = match file_sha256(path) {
369            Ok(actual) => actual,
370            Err(error) => {
371                return OpenH264Status::Unusable(format!("{shown} cannot be read: {error}"));
372            }
373        };
374        if actual != expected {
375            return OpenH264Status::Unusable(format!(
376                "{shown} is not Cisco's OpenH264 {OPENH264_VERSION} binary for this platform \
377                 (SHA-256 {actual}, expected {expected})"
378            ));
379        }
380    }
381    match ffi::Api::load(path) {
382        Ok(api) => checked(api, provider, &shown),
383        Err(error) => OpenH264Status::Unusable(refusal(&shown, &error) + &quarantine_note(path)),
384    }
385}
386
387/// For a quarantined library on macOS (Gatekeeper refuses unnotarized ones), how to remove the
388/// attribute. Empty elsewhere.
389fn quarantine_note(path: &Path) -> String {
390    if !alpha2_os::quarantined(path) {
391        return String::new();
392    }
393    format!(
394        "; macOS quarantined it (a browser download), and Gatekeeper refuses an unnotarized \
395         library: remove the attribute (xattr -d com.apple.quarantine '{}') or install it with \
396         python -m alpha2 install --openh264 or npx @alpha2-sh/node install --openh264",
397        path.display()
398    )
399}
400
401/// Accepts a loaded library whose API version the FFI was written against.
402fn checked(api: ffi::Api, provider: OpenH264Provider, shown: &str) -> OpenH264Status {
403    let version = api.version();
404    if !COMPATIBLE_API.contains(&(version.major, version.minor)) {
405        return OpenH264Status::Unusable(format!(
406            "{shown} is OpenH264 {}.{}.{}, and alpha2 is built against the 2.6/2.7 API",
407            version.major, version.minor, version.revision
408        ));
409    }
410    OpenH264Status::Available(Arc::new(OpenH264Library {
411        api: Arc::new(api),
412        provider,
413        version: format!("{}.{}.{}", version.major, version.minor, version.revision),
414        path: shown.to_owned(),
415    }))
416}
417
418fn refusal(shown: &str, error: &ffi::LoadError) -> String {
419    match error {
420        ffi::LoadError::Open(message) => {
421            format!("{shown} was found but is not loadable: {message}")
422        }
423        ffi::LoadError::Symbol(symbol, message) => {
424            format!("{shown} is not an OpenH264 encoder library (no {symbol}: {message})")
425        }
426    }
427}
428
429/// The SHA-256 of the file at `path`, in lower-case hex.
430pub(crate) fn file_sha256(path: &Path) -> std::io::Result<String> {
431    use std::io::Read;
432    let mut file = std::fs::File::open(path)?;
433    let mut hasher = Sha256::new();
434    let mut buffer = vec![0_u8; 1 << 16];
435    loop {
436        let read = file.read(&mut buffer)?;
437        if read == 0 {
438            break;
439        }
440        hasher.update(&buffer[..read]);
441    }
442    Ok(hasher
443        .finalize()
444        .iter()
445        .fold(String::with_capacity(64), |mut hex, byte| {
446            let _ = write!(hex, "{byte:02x}");
447            hex
448        }))
449}