449 lines
14 KiB
Rust
449 lines
14 KiB
Rust
use std::fs;
|
|
use std::io::{Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use super::database::Database;
|
|
|
|
/// Information about an AppImage's runtime binary.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RuntimeInfo {
|
|
pub runtime_size: u64,
|
|
pub payload_offset: u64,
|
|
pub runtime_type: RuntimeType,
|
|
pub runtime_version: Option<String>,
|
|
}
|
|
|
|
/// The type of AppImage runtime.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum RuntimeType {
|
|
OldFuse2,
|
|
NewMulti,
|
|
Static,
|
|
Unknown,
|
|
}
|
|
|
|
impl RuntimeType {
|
|
pub fn as_str(&self) -> &str {
|
|
match self {
|
|
Self::OldFuse2 => "old-fuse2",
|
|
Self::NewMulti => "new-multi",
|
|
Self::Static => "static",
|
|
Self::Unknown => "unknown",
|
|
}
|
|
}
|
|
|
|
pub fn label(&self) -> &str {
|
|
match self {
|
|
Self::OldFuse2 => "Legacy FUSE 2 only",
|
|
Self::NewMulti => "Multi-runtime (FUSE 2/3 + static)",
|
|
Self::Static => "Static (no FUSE needed)",
|
|
Self::Unknown => "Unknown runtime",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Result of a runtime replacement operation.
|
|
#[derive(Debug)]
|
|
pub struct RepackageResult {
|
|
pub original_path: PathBuf,
|
|
pub backup_path: PathBuf,
|
|
pub old_runtime_type: RuntimeType,
|
|
pub new_runtime_type: String,
|
|
pub old_size: u64,
|
|
pub new_size: u64,
|
|
pub success: bool,
|
|
}
|
|
|
|
/// Detect the runtime type and payload offset of an AppImage.
|
|
/// Type 2 AppImages store the SquashFS offset in the ELF section header.
|
|
pub fn detect_runtime(appimage_path: &Path) -> Result<RuntimeInfo, RepackageError> {
|
|
let mut file = fs::File::open(appimage_path)
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
|
|
// Read ELF header to find section headers
|
|
let mut header = [0u8; 64];
|
|
file.read_exact(&mut header)
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
|
|
// Verify ELF magic
|
|
if &header[0..4] != b"\x7fELF" {
|
|
return Err(RepackageError::NotAppImage("Not an ELF file".to_string()));
|
|
}
|
|
|
|
// Find the SquashFS payload by searching for the magic bytes
|
|
let payload_offset = find_squashfs_offset(appimage_path)?;
|
|
|
|
let runtime_size = payload_offset;
|
|
|
|
// Classify the runtime type based on size and content
|
|
let runtime_type = classify_runtime(appimage_path, runtime_size)?;
|
|
|
|
Ok(RuntimeInfo {
|
|
runtime_size,
|
|
payload_offset,
|
|
runtime_type,
|
|
runtime_version: None,
|
|
})
|
|
}
|
|
|
|
/// Find the offset where the SquashFS payload starts.
|
|
/// SquashFS magic is 'hsqs' (0x73717368) at the start of the payload.
|
|
fn find_squashfs_offset(appimage_path: &Path) -> Result<u64, RepackageError> {
|
|
let mut file = fs::File::open(appimage_path)
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
|
|
let file_size = file.metadata()
|
|
.map(|m| m.len())
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
|
|
// SquashFS magic: 'hsqs' = [0x68, 0x73, 0x71, 0x73]
|
|
let magic = b"hsqs";
|
|
|
|
// Search in chunks starting from reasonable offsets (runtime is typically 100-300KB)
|
|
let mut buf = [0u8; 65536];
|
|
let search_start = 4096u64; // Skip the ELF header
|
|
let search_end = std::cmp::min(file_size, 1_048_576); // Don't search beyond 1MB
|
|
|
|
let mut offset = search_start;
|
|
use std::io::Seek;
|
|
file.seek(std::io::SeekFrom::Start(offset))
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
|
|
while offset < search_end {
|
|
let n = file.read(&mut buf)
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
if n == 0 { break; }
|
|
|
|
// Search for magic in this chunk
|
|
for i in 0..n.saturating_sub(3) {
|
|
if &buf[i..i + 4] == magic {
|
|
return Ok(offset + i as u64);
|
|
}
|
|
}
|
|
|
|
offset += n as u64 - 3; // Overlap by 3 to catch magic spanning chunks
|
|
file.seek(std::io::SeekFrom::Start(offset))
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
}
|
|
|
|
Err(RepackageError::NotAppImage("SquashFS payload not found".to_string()))
|
|
}
|
|
|
|
/// Classify the runtime type based on its binary content.
|
|
fn classify_runtime(appimage_path: &Path, runtime_size: u64) -> Result<RuntimeType, RepackageError> {
|
|
let mut file = fs::File::open(appimage_path)
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
|
|
let read_size = std::cmp::min(runtime_size, 65536) as usize;
|
|
let mut buf = vec![0u8; read_size];
|
|
file.read_exact(&mut buf)
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
|
|
let content = String::from_utf8_lossy(&buf);
|
|
|
|
// Check for known strings in the runtime binary
|
|
if content.contains("libfuse3") || content.contains("fuse3") {
|
|
Ok(RuntimeType::NewMulti)
|
|
} else if content.contains("static-runtime") || content.contains("no-fuse") {
|
|
Ok(RuntimeType::Static)
|
|
} else if content.contains("libfuse") || content.contains("fuse2") {
|
|
Ok(RuntimeType::OldFuse2)
|
|
} else if runtime_size < 4096 {
|
|
// Suspiciously small runtime - probably not a valid AppImage runtime
|
|
Ok(RuntimeType::Unknown)
|
|
} else {
|
|
// Default: older runtimes are typically fuse2-only
|
|
Ok(RuntimeType::OldFuse2)
|
|
}
|
|
}
|
|
|
|
/// Replace the runtime of an AppImage with a new one.
|
|
/// Creates a backup of the original file before modifying.
|
|
pub fn replace_runtime(
|
|
appimage_path: &Path,
|
|
new_runtime_path: &Path,
|
|
keep_backup: bool,
|
|
) -> Result<RepackageResult, RepackageError> {
|
|
if !appimage_path.exists() {
|
|
return Err(RepackageError::NotAppImage("File not found".to_string()));
|
|
}
|
|
if !new_runtime_path.exists() {
|
|
return Err(RepackageError::Io("New runtime file not found".to_string()));
|
|
}
|
|
|
|
let info = detect_runtime(appimage_path)?;
|
|
let old_size = fs::metadata(appimage_path)
|
|
.map(|m| m.len())
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
|
|
// Create backup
|
|
let backup_path = appimage_path.with_extension("bak");
|
|
fs::copy(appimage_path, &backup_path)
|
|
.map_err(|e| RepackageError::Io(format!("Backup failed: {}", e)))?;
|
|
|
|
// Read new runtime
|
|
let new_runtime = fs::read(new_runtime_path)
|
|
.map_err(|e| RepackageError::Io(format!("Failed to read new runtime: {}", e)))?;
|
|
|
|
// Read the SquashFS payload from the original file
|
|
let mut original = fs::File::open(appimage_path)
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
use std::io::Seek;
|
|
original.seek(std::io::SeekFrom::Start(info.payload_offset))
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
|
|
let mut payload = Vec::new();
|
|
original.read_to_end(&mut payload)
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
drop(original);
|
|
|
|
// Write new AppImage: new_runtime + payload
|
|
let mut output = fs::File::create(appimage_path)
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
output.write_all(&new_runtime)
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
output.write_all(&payload)
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
|
|
// Set executable permission
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let perms = fs::Permissions::from_mode(0o755);
|
|
fs::set_permissions(appimage_path, perms).ok();
|
|
}
|
|
|
|
let new_size = fs::metadata(appimage_path)
|
|
.map(|m| m.len())
|
|
.unwrap_or(0);
|
|
|
|
// Verify the new file is a valid AppImage
|
|
let success = verify_appimage(appimage_path);
|
|
|
|
if !success {
|
|
// Rollback from backup
|
|
log::error!("Verification failed, rolling back from backup");
|
|
fs::copy(&backup_path, appimage_path).ok();
|
|
if !keep_backup {
|
|
fs::remove_file(&backup_path).ok();
|
|
}
|
|
return Err(RepackageError::VerificationFailed);
|
|
}
|
|
|
|
if !keep_backup {
|
|
fs::remove_file(&backup_path).ok();
|
|
}
|
|
|
|
Ok(RepackageResult {
|
|
original_path: appimage_path.to_path_buf(),
|
|
backup_path,
|
|
old_runtime_type: info.runtime_type,
|
|
new_runtime_type: "new".to_string(),
|
|
old_size,
|
|
new_size,
|
|
success: true,
|
|
})
|
|
}
|
|
|
|
/// Batch-replace runtimes for all AppImages in the database that use the old runtime.
|
|
pub fn batch_replace_runtimes(
|
|
db: &Database,
|
|
new_runtime_path: &Path,
|
|
dry_run: bool,
|
|
) -> Vec<RepackageResult> {
|
|
let records = db.get_all_appimages().unwrap_or_default();
|
|
let mut results = Vec::new();
|
|
|
|
for record in &records {
|
|
let path = Path::new(&record.path);
|
|
if !path.exists() {
|
|
continue;
|
|
}
|
|
|
|
let info = match detect_runtime(path) {
|
|
Ok(i) => i,
|
|
Err(e) => {
|
|
log::warn!("Skipping {}: {}", record.filename, e);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Only repackage old fuse2 runtimes
|
|
if info.runtime_type != RuntimeType::OldFuse2 {
|
|
continue;
|
|
}
|
|
|
|
if dry_run {
|
|
results.push(RepackageResult {
|
|
original_path: path.to_path_buf(),
|
|
backup_path: path.with_extension("bak"),
|
|
old_runtime_type: info.runtime_type,
|
|
new_runtime_type: "new".to_string(),
|
|
old_size: fs::metadata(path).map(|m| m.len()).unwrap_or(0),
|
|
new_size: 0,
|
|
success: true,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
match replace_runtime(path, new_runtime_path, true) {
|
|
Ok(result) => {
|
|
// Record in database
|
|
db.record_runtime_update(
|
|
record.id,
|
|
Some(info.runtime_type.as_str()),
|
|
Some("new"),
|
|
result.backup_path.to_str(),
|
|
true,
|
|
).ok();
|
|
results.push(result);
|
|
}
|
|
Err(e) => {
|
|
log::error!("Failed to repackage {}: {}", record.filename, e);
|
|
db.record_runtime_update(
|
|
record.id,
|
|
Some(info.runtime_type.as_str()),
|
|
Some("new"),
|
|
None,
|
|
false,
|
|
).ok();
|
|
}
|
|
}
|
|
}
|
|
|
|
results
|
|
}
|
|
|
|
/// Download the latest AppImage runtime binary.
|
|
pub fn download_latest_runtime() -> Result<PathBuf, RepackageError> {
|
|
let url = "https://github.com/AppImage/type2-runtime/releases/latest/download/runtime-x86_64";
|
|
|
|
let dest = dirs::cache_dir()
|
|
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
|
.join("driftwood")
|
|
.join("runtime-x86_64");
|
|
|
|
fs::create_dir_all(dest.parent().unwrap()).ok();
|
|
|
|
let response = ureq::get(url)
|
|
.call()
|
|
.map_err(|e| RepackageError::Network(e.to_string()))?;
|
|
|
|
let mut file = fs::File::create(&dest)
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
|
|
let mut reader = response.into_body().into_reader();
|
|
let mut buf = [0u8; 65536];
|
|
loop {
|
|
let n = reader.read(&mut buf)
|
|
.map_err(|e| RepackageError::Network(e.to_string()))?;
|
|
if n == 0 { break; }
|
|
file.write_all(&buf[..n])
|
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(&dest, fs::Permissions::from_mode(0o755)).ok();
|
|
}
|
|
|
|
Ok(dest)
|
|
}
|
|
|
|
/// Basic verification that a file is still a valid AppImage.
|
|
fn verify_appimage(path: &Path) -> bool {
|
|
// Check ELF magic
|
|
let mut file = match fs::File::open(path) {
|
|
Ok(f) => f,
|
|
Err(_) => return false,
|
|
};
|
|
|
|
let mut magic = [0u8; 4];
|
|
if file.read_exact(&mut magic).is_err() {
|
|
return false;
|
|
}
|
|
if &magic != b"\x7fELF" {
|
|
return false;
|
|
}
|
|
|
|
// Check that SquashFS payload exists
|
|
find_squashfs_offset(path).is_ok()
|
|
}
|
|
|
|
// --- Error types ---
|
|
|
|
#[derive(Debug)]
|
|
pub enum RepackageError {
|
|
NotAppImage(String),
|
|
Io(String),
|
|
Network(String),
|
|
VerificationFailed,
|
|
}
|
|
|
|
impl std::fmt::Display for RepackageError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::NotAppImage(e) => write!(f, "Not a valid AppImage: {}", e),
|
|
Self::Io(e) => write!(f, "I/O error: {}", e),
|
|
Self::Network(e) => write!(f, "Network error: {}", e),
|
|
Self::VerificationFailed => write!(f, "Verification failed after repackaging"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_runtime_type_as_str() {
|
|
assert_eq!(RuntimeType::OldFuse2.as_str(), "old-fuse2");
|
|
assert_eq!(RuntimeType::NewMulti.as_str(), "new-multi");
|
|
assert_eq!(RuntimeType::Static.as_str(), "static");
|
|
assert_eq!(RuntimeType::Unknown.as_str(), "unknown");
|
|
}
|
|
|
|
#[test]
|
|
fn test_runtime_type_label() {
|
|
assert!(RuntimeType::OldFuse2.label().contains("Legacy"));
|
|
assert!(RuntimeType::NewMulti.label().contains("Multi"));
|
|
assert!(RuntimeType::Static.label().contains("no FUSE"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_repackage_error_display() {
|
|
let err = RepackageError::NotAppImage("bad magic".to_string());
|
|
assert!(format!("{}", err).contains("bad magic"));
|
|
let err = RepackageError::VerificationFailed;
|
|
assert!(format!("{}", err).contains("Verification failed"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_runtime_nonexistent() {
|
|
let result = detect_runtime(Path::new("/nonexistent.AppImage"));
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_runtime_not_elf() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("not-an-elf");
|
|
fs::write(&path, "This is not an ELF file").unwrap();
|
|
let result = detect_runtime(&path);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_appimage_nonexistent() {
|
|
assert!(!verify_appimage(Path::new("/nonexistent")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_appimage_not_elf() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("not-elf");
|
|
fs::write(&path, "hello").unwrap();
|
|
assert!(!verify_appimage(&path));
|
|
}
|
|
}
|