// SPDX-FileCopyrightText: 2025 Alyssa Ross // SPDX-License-Identifier: EUPL-1.2+ //! Flatpak installations look like this: //! //! ```text //! flatpak/ //! ├── app/ //! │   └── org.gnome.TextEditor/ //! │   ├── current -> x86_64/stable //! │   └── x86_64/ //! │   └── stable/ //! │   ├── 0029140121b39f5b7cf4d44fd46b0708eee67f395b5e1291628612a0358fb909/ //! │   │   └── … //! │   └── active -> 0029140121b39f5b7cf4d44fd46b0708eee67f395b5e1291628612a0358fb909 //! ├── db/ //! ├── exports/ //! │   └── … //! ├── repo //! │   ├── config //! │   ├── objects //! │   ├── tmp //! │   │   └── cache //! │   │   └── … //! │   └── … //! └── runtime //! ├── org.gnome.Platform //! │   └── x86_64 //! │   └── 49 //! │   ├── active -> bf6aa432cb310726f4ac0ec08cc88558619e1d4bd4b964e27e95187ecaad5400 //! │   └── bf6aa432cb310726f4ac0ec08cc88558619e1d4bd4b964e27e95187ecaad5400 //! │   └── … //! └── … //! ``` //! //! The purpose of this program is to use bind mounts to construct a //! Flatpak installation containing only a single application and //! runtime, which can be passed through to a VM without exposing //! other installed applications. mod keyfile; mod metadata; use std::borrow::Cow; use std::env::{ArgsOs, args_os}; use std::ffi::{OsStr, OsString}; use std::io; use std::os::unix::prelude::*; use std::path::{Path, PathBuf}; use std::process::exit; use pathrs::flags::{OpenFlags, ResolverFlags}; use pathrs::{Root, error::Error, error::ErrorKind}; use rustix::fs::{CWD, FileType, fstat}; use rustix::mount::{MoveMountFlags, OpenTreeFlags, move_mount, open_tree}; use metadata::extract_runtime; fn ex_usage() -> ! { eprintln!("Usage: mount-flatpak userdata installation app"); exit(1); } fn open_subdir(root: &Root, path: &Path) -> Result { root.open_subpath(path, OpenFlags::O_PATH | OpenFlags::O_DIRECTORY) .map(|f| Root::from_fd(f).with_resolver_flags(ResolverFlags::NO_SYMLINKS)) } fn check_name(slice: &[u8], expected_slashes: usize, kind: &str) -> Result<(), String> { // Application ID is provided by a human. Give useful error messages if it is wrong. let mut components = 0usize; for component in slice.split(|&s| s == b'/') { components += 1; match component { // Leading, trailing, and repeated '/' introduce empty slices // into the result, which are caught here. &[] | &[b'.'] | &[b'.', b'.'] => { return Err(format!("Path component {components} is {component:?}. Your Flatpak repository is corrupt.")); } _ => {} } if component.len() > 255 { return Err(format!("{kind} has component with length over 255 bytes. Your Flatpak repository is corrupt")); } for c in component { match c { // This can happen for bad metadata files. b'\0' => return Err(format!("{kind} contains a NUL byte. Your Flatpak metadata is bad.")), _ => {} } } } components -= 1; if expected_slashes != components { return Err(format!( "{kind} should have {expected_slashes} '/' characters, \ but it has {components}. Your Flatpak repository is corrupt." )); } Ok(()) } fn mount_commit( source_installation: &Root, source_path: &Path, target_installation: &Root, target_path: &Path, kind: &str, ) -> Result<(Root, OsString), String> { let source_commit_parent_dir = open_subdir(source_installation, source_path) .map_err(|e| format!("opening source {kind} commit parent: {e}"))?; let commit = source_commit_parent_dir .readlink("active") .map_err(|e| format!("reading active {kind} commit: {e}"))?; check_name(commit.as_os_str().as_bytes(), 0, "commit")?; let source_root = open_subdir(&source_commit_parent_dir, &commit) .map_err(|e| format!("opening source {kind} commit: {e}"))?; let source_commit_tree = open_tree( &source_root, "", OpenTreeFlags::AT_EMPTY_PATH | OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC | OpenTreeFlags::AT_RECURSIVE, ) .map_err(|e| format!("cloning source commit tree: {e}"))?; let target_commit_dir = target_installation .mkdir_all(target_path, &PermissionsExt::from_mode(0o700)) .map_err(|e| format!("creating target commit directory: {e}"))?; move_mount( source_commit_tree, "", target_commit_dir, "", MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH | MoveMountFlags::MOVE_MOUNT_T_EMPTY_PATH, ) .map_err(|e| format!("mounting commit: {e}"))?; Ok((source_root, commit.as_os_str().to_owned())) } // Check that the application ID is reasonable. Problems with it // are almost always human error, and it is possible to provide // vastly better error messages this way. Furthermore, checking the // application ID here means that if it is okay, it can be printed in // logs without further sanitization. fn check_app_id(app: &[u8]) -> Result<(), String> { let app_len = app.len(); if app_len > 255 { // Avoid confusing ENAMETOOLONG error return Err(format!("Application ID is {app_len} bytes, but the limit is 255")); } match app.get(0) { None => return Err("Application ID can't be empty".to_string()), Some(a) => match a { b'.' | b'-' | b'/' => return Err(format!("Application ID can't start with '{a}'")), _ => {} }, }; for c in app { match c { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'-' | b'_' => {} _ => return Err(format!("Application ID has forbidden characters. Only A-Z, a-z, 0-9, ., _, and - are allowed.")), } } Ok(()) } fn run(mut args: ArgsOs) -> Result<(), String> { let Some(user_data_path) = args.next().map(PathBuf::from) else { ex_usage(); }; let Some(installation_path) = args.next().map(PathBuf::from) else { ex_usage(); }; let Some(app) = args.next() else { ex_usage(); }; if args.next().is_some() { ex_usage(); } check_app_id(app.as_bytes())?; let user_data = Root::open(&user_data_path) .map_err(|e| format!("opening user data partition: {e}"))? .with_resolver_flags(ResolverFlags::NO_SYMLINKS); let source_installation_dir = open_subdir(&user_data, &installation_path) .map_err(|e| format!("opening source flatpak installation: {e}"))?; std::fs::create_dir("flatpak") .map_err(|e| format!("creating target flatpak installation: {e}"))?; let target_installation_dir = open_tree( CWD, "flatpak", OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC | OpenTreeFlags::AT_RECURSIVE | OpenTreeFlags::AT_SYMLINK_NOFOLLOW, ) .map_err(|e| format!("opening target flatpak installation: {e}"))?; let target_installation_dir = Root::from_fd(target_installation_dir).with_resolver_flags(ResolverFlags::NO_SYMLINKS); let source_app_dir = { let app_root_dir = match open_subdir(&source_installation_dir, &Path::new("app")) { Ok(d) => d, Err(e) => match e.kind() { ErrorKind::OsError(Some(libc::ENOENT)) => { return Err("Can't find the \"app\" directory. This probably isn't a flatpak repository".to_string()) } _ => return Err(format!("opening source app directory: {e}")), } }; match open_subdir(&app_root_dir, app.as_ref()) { Ok(d) => d, Err(e) => match e.kind() { ErrorKind::OsError(Some(libc::ENOENT)) => { // app was santized above, so no need to redact it from logs // also, this must be valid UTF-8 let app = str::from_utf8(app.as_bytes()).unwrap(); return Err(format!("Application {app} isn't installed.")) } _ => return Err(format!("opening source per-app directory: {e}")), } } }; let arch_and_branch = source_app_dir .readlink("current") .map_err(|e| format!("reading current app arch and branch: {e}"))?; check_name(arch_and_branch.as_os_str().as_bytes(), 1, "arch/branch")?; let mut components = arch_and_branch.components(); let arch = components.next().unwrap().as_os_str(); let branch = components.as_path().as_os_str(); if branch.is_empty() { return Err("can't infer branch from \"current\" link".to_string()); } let (source_commit_dir, commit) = mount_commit( &source_app_dir, &PathBuf::new().join("app").join(&app).join(&arch_and_branch), &target_installation_dir, &arch_and_branch, "app", )?; let metadata = source_commit_dir .resolve("metadata") .map_err(|e| format!("resolving app metadata: {e}"))?; let metadata_stat = fstat(&metadata).map_err(|e| format!("checking app metadata is a regular file: {e}"))?; let metadata_type = FileType::from_raw_mode(metadata_stat.st_mode); if !metadata_type.is_file() { let e = format!("type of app metadata is {metadata_type:?}, not RegularFile"); return Err(e); } let metadata = metadata .reopen(OpenFlags::O_RDONLY) .map_err(|e| format!("opening app metadata: {e}"))?; let runtime = extract_runtime(metadata).map_err(|e| format!("reading runtime from metadata: {e}"))?; check_name(runtime.as_bytes(), 2, "runtime/architecture/version")?; let runtime_path = Path::new("runtime").join(runtime); let (_, runtime_commit) = mount_commit( &source_installation_dir, &runtime_path, &target_installation_dir, &runtime_path, "runtime", )?; target_installation_dir .mkdir_all("repo/objects", &PermissionsExt::from_mode(0o700)) .map_err(|e| format!("creating repo/objects: {e}"))?; target_installation_dir .mkdir_all("repo/tmp/cache", &PermissionsExt::from_mode(0o700)) .map_err(|e| format!("creating repo/tmp/cache: {e}"))?; let config_target = target_installation_dir .create_file( "repo/config", OpenFlags::O_WRONLY | OpenFlags::O_CLOEXEC, &PermissionsExt::from_mode(0o700), ) .map_err(|e| format!("creating repo/config: {e}"))?; let config_source_path = env!("MOUNT_FLATPAK_CONFIG_PATH"); let config_source = open_tree( CWD, config_source_path, OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC, ) .map_err(|e| format!("opening {config_source_path}: {e}"))?; move_mount( config_source, "", config_target, "", MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH | MoveMountFlags::MOVE_MOUNT_T_EMPTY_PATH, ) .map_err(|e| format!("mounting config: {e}"))?; let mut attr = libc::mount_attr { attr_clr: libc::MOUNT_ATTR_NOSYMFOLLOW, attr_set: libc::MOUNT_ATTR_RDONLY | libc::MOUNT_ATTR_NODEV, propagation: libc::MS_SLAVE, userns_fd: 0, }; let empty = b"\0"; // SAFETY: we pass a valid FD, valid C string, and a valid mutable pointer with the // correct size. unsafe { let r = libc::syscall( libc::SYS_mount_setattr, target_installation_dir.as_fd().as_raw_fd() as libc::c_long, empty.as_ptr() as *const libc::c_char, (libc::AT_EMPTY_PATH | libc::AT_RECURSIVE) as libc::c_long, &mut attr as *mut libc::mount_attr, size_of::() as libc::c_long, ); if r == -1 { return Err(format!( "setting target mount attributes: {}", io::Error::last_os_error() )); } } move_mount( target_installation_dir, "", CWD, "flatpak", MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH, ) .map_err(|e| format!("mounting target installation dir: {e}"))?; std::fs::create_dir("params").map_err(|e| format!("creating params directory: {e}"))?; std::fs::write("params/id", app.as_bytes()).map_err(|e| format!("writing params/id: {e}"))?; std::fs::write("params/commit", commit.as_bytes()) .map_err(|e| format!("writing params/commit: {e}"))?; std::fs::write("params/arch", arch.as_bytes()) .map_err(|e| format!("writing params/arch: {e}"))?; std::fs::write("params/branch", branch.as_bytes()) .map_err(|e| format!("writing params/branch: {e}"))?; std::fs::write("params/runtime-commit", runtime_commit.as_bytes()) .map_err(|e| format!("writing params/runtime-commit: {e}"))?; Ok(()) } fn main() { let mut args = args_os(); let prog_name = args .next() .as_ref() .map(Path::new) .and_then(Path::file_name) .map_or(Cow::Borrowed("mount-flatpak"), OsStr::to_string_lossy) .into_owned(); if let Err(e) = run(args) { eprintln!("{prog_name}: {e}"); exit(1); } }