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