diff --git a/scripts/release-workflow.node-test.js b/scripts/release-workflow.node-test.js
index 708fd78..af5073a 100644
--- a/scripts/release-workflow.node-test.js
+++ b/scripts/release-workflow.node-test.js
@@ -23,3 +23,19 @@ test('macOS release verification uses the app mounted from the final DMG', () =>
assert.match(releaseWorkflow, /node scripts\/verify-binaries\.js --search-root "\$APP"/);
assert.doesNotMatch(releaseWorkflow, /verify:macos-signing -- --app "\$APP" --dmg/);
});
+
+test('release workflow normalizes all 6 distribution target artifacts', () => {
+ assert.match(releaseWorkflow, /rename_asset '\*\.dmg' "Firelink_\$\{VERSION\}_macOS-ARM64\.dmg"/);
+ assert.match(releaseWorkflow, /rename_asset '\*\.AppImage' "Firelink_\$\{VERSION\}_Linux-x64\.AppImage"/);
+ assert.match(releaseWorkflow, /rename_asset '\*\.deb' "Firelink_\$\{VERSION\}_Linux-x64\.deb"/);
+ assert.match(releaseWorkflow, /rename_asset '\*\.rpm' "Firelink_\$\{VERSION\}_Linux-x64\.rpm"/);
+ assert.match(releaseWorkflow, /rename_asset '\*\.exe' "Firelink_\$\{VERSION\}_Windows-x64-setup\.exe"/);
+ assert.match(releaseWorkflow, /rename_asset '\*\.zip' "Firelink_\$\{VERSION\}_Windows-x64-portable\.zip"/);
+});
+
+test('Windows release job packages portable ZIP with portable.flag and data cleanup', () => {
+ assert.match(releaseWorkflow, /Set-Content -Path \(Join-Path \$portableRoot 'portable\.flag'\) -Value 'portable'/);
+ assert.match(releaseWorkflow, /node scripts\/smoke-packaged-app\.js --executable \$portableExe --assert-no-visible-child-windows --assert-portable-data/);
+ assert.match(releaseWorkflow, /Remove-Item -Recurse -Force \$portableDataDir/);
+ assert.match(releaseWorkflow, /refusing to package a ZIP containing runtime data/);
+});
diff --git a/scripts/verify-companion-release.js b/scripts/verify-companion-release.js
index 11f3a7d..2955dad 100644
--- a/scripts/verify-companion-release.js
+++ b/scripts/verify-companion-release.js
@@ -12,12 +12,20 @@ function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
-function exactVersionTag(extensionRoot, expectedTag) {
+export function exactVersionTag(extensionRoot, expectedTag) {
try {
const tags = execFileSync(
'git',
['-C', extensionRoot, 'tag', '--points-at', 'HEAD', '--list', '--', expectedTag],
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
+ {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'ignore'],
+ env: {
+ ...process.env,
+ GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL || (process.platform === 'win32' ? 'NUL' : '/dev/null'),
+ GIT_CONFIG_NOSYSTEM: '1',
+ },
+ }
)
.split(/\r?\n/)
.map(tag => tag.trim())
diff --git a/scripts/verify-companion-release.node-test.js b/scripts/verify-companion-release.node-test.js
index 37811e9..8fc62e7 100644
--- a/scripts/verify-companion-release.node-test.js
+++ b/scripts/verify-companion-release.node-test.js
@@ -3,7 +3,8 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
-import { verifyCompanionRelease } from './verify-companion-release.js';
+import { execFileSync } from 'node:child_process';
+import { exactVersionTag, verifyCompanionRelease } from './verify-companion-release.js';
function createFixture(packageVersion, manifestVersion) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-companion-release-'));
@@ -116,3 +117,26 @@ test('rejects a Companion tag for another version', () => {
fs.rmSync(root, { recursive: true, force: true });
}
});
+
+test('exactVersionTag resolves tag on HEAD with isolated git environment', () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-git-test-'));
+ try {
+ const gitEnv = {
+ ...process.env,
+ GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
+ GIT_CONFIG_NOSYSTEM: '1',
+ GIT_AUTHOR_NAME: 'Test',
+ GIT_AUTHOR_EMAIL: 'test@example.com',
+ GIT_COMMITTER_NAME: 'Test',
+ GIT_COMMITTER_EMAIL: 'test@example.com',
+ };
+ execFileSync('git', ['init', root], { env: gitEnv, stdio: 'ignore' });
+ execFileSync('git', ['-C', root, 'commit', '--allow-empty', '-m', 'test'], { env: gitEnv, stdio: 'ignore' });
+ execFileSync('git', ['-C', root, 'tag', 'v2.0.7'], { env: gitEnv, stdio: 'ignore' });
+
+ assert.equal(exactVersionTag(root, 'v2.0.7'), 'v2.0.7');
+ assert.equal(exactVersionTag(root, 'v2.0.8'), null);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs
index 3fd8a4c..dcb270a 100644
--- a/src-tauri/src/extension_server.rs
+++ b/src-tauri/src/extension_server.rs
@@ -731,8 +731,9 @@ mod tests {
use super::{
acknowledge_extension_download, add_server_identity, claim_request_at,
has_allowed_request_origin, is_valid_client_nonce, normalize_download,
- required_client_nonce, sign_server_proof, ExtensionCookieScope, ExtensionRequest,
- MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
+ normalize_url, required_client_nonce, same_origin_url, sanitize_filename,
+ sign_server_proof, ExtensionCookieScope, ExtensionRequest, MAX_URL_COUNT,
+ PROTOCOL_VERSION_HEADER, SERVER_HEADER,
};
use axum::{
http::{HeaderMap, HeaderValue, StatusCode},
@@ -1182,4 +1183,70 @@ mod tests {
expected
);
}
+
+ #[test]
+ fn sanitize_filename_strips_path_traversal_and_rejects_empty_or_special_names() {
+ assert_eq!(sanitize_filename("../../etc/passwd"), Some("passwd".to_string()));
+ assert_eq!(
+ sanitize_filename(r"..\..\Windows\System32\calc.exe"),
+ Some("calc.exe".to_string())
+ );
+ assert_eq!(
+ sanitize_filename("valid_report.pdf"),
+ Some("valid_report.pdf".to_string())
+ );
+ assert!(sanitize_filename(".").is_none());
+ assert!(sanitize_filename("..").is_none());
+ assert!(sanitize_filename("").is_none());
+ assert!(sanitize_filename(" ").is_none());
+ assert!(sanitize_filename(&"a".repeat(256)).is_none());
+ }
+
+ #[test]
+ fn normalize_url_rejects_dangerous_or_unsupported_schemes() {
+ assert!(normalize_url("file:///etc/passwd").is_none());
+ assert!(normalize_url("javascript:alert(1)").is_none());
+ assert!(normalize_url("data:text/html,
test
").is_none());
+ assert!(normalize_url("blob:https://example.com/uuid").is_none());
+ assert_eq!(
+ normalize_url("https://example.com/file.zip"),
+ Some("https://example.com/file.zip".to_string())
+ );
+ assert_eq!(
+ normalize_url("http://example.com/file.zip"),
+ Some("http://example.com/file.zip".to_string())
+ );
+ assert_eq!(
+ normalize_url("ftp://example.com/file.zip"),
+ Some("ftp://example.com/file.zip".to_string())
+ );
+ assert_eq!(
+ normalize_url("sftp://example.com/file.zip"),
+ Some("sftp://example.com/file.zip".to_string())
+ );
+ assert_eq!(
+ normalize_url("magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567"),
+ Some("magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567".to_string())
+ );
+ }
+
+ #[test]
+ fn same_origin_url_strictly_matches_scheme_host_and_port() {
+ assert!(same_origin_url(
+ "https://example.com/path1",
+ "https://example.com/path2"
+ ));
+ assert!(!same_origin_url(
+ "http://example.com/path",
+ "https://example.com/path"
+ ));
+ assert!(!same_origin_url(
+ "https://example.com:8443/path",
+ "https://example.com/path"
+ ));
+ assert!(!same_origin_url(
+ "https://other.example/path",
+ "https://example.com/path"
+ ));
+ }
}
diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs
index ff79b67..0c5c799 100644
--- a/src-tauri/src/ipc.rs
+++ b/src-tauri/src/ipc.rs
@@ -570,7 +570,7 @@ pub enum ListRowDensity {
Relaxed,
}
-#[derive(Clone, Debug, Serialize, Deserialize, TS)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum PostQueueAction {
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index e7ef153..1a600cb 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -2640,6 +2640,13 @@ async fn fetch_media_metadata(
) -> Result {
properties_window::ensure_main_window(&caller)?;
validate_http_url_route(&url)?;
+ let cookie_browser = normalize_media_cookie_source(cookie_browser.as_deref())?;
+ let user_agent = user_agent.map(|ua| ua.trim().to_string()).filter(|ua| !ua.is_empty());
+ let username = username.map(|u| u.trim().to_string()).filter(|u| !u.is_empty());
+ let password = password.filter(|p| !p.is_empty());
+ let headers = headers.map(|h| h.trim().to_string()).filter(|h| !h.is_empty());
+ let cookies = cookies.map(|c| c.trim().to_string()).filter(|c| !c.is_empty());
+ let proxy = proxy.map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
let cache_key = media_metadata_cache_key(
&url,
&cookie_browser,
@@ -2777,6 +2784,13 @@ async fn fetch_media_playlist_metadata(
) -> Result {
properties_window::ensure_main_window(&caller)?;
validate_http_url_route(&url)?;
+ let cookie_browser = normalize_media_cookie_source(cookie_browser.as_deref())?;
+ let user_agent = user_agent.map(|ua| ua.trim().to_string()).filter(|ua| !ua.is_empty());
+ let username = username.map(|u| u.trim().to_string()).filter(|u| !u.is_empty());
+ let password = password.filter(|p| !p.is_empty());
+ let headers = headers.map(|h| h.trim().to_string()).filter(|h| !h.is_empty());
+ let cookies = cookies.map(|c| c.trim().to_string()).filter(|c| !c.is_empty());
+ let proxy = proxy.map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
let result = fetch_media_playlist_metadata_uncached(
app_handle.clone(),
@@ -2862,8 +2876,8 @@ async fn fetch_media_playlist_metadata_uncached(
.arg("no-youtube-unavailable-videos");
if let Some(browser) = cookie_browser.as_deref() {
- if !browser.is_empty() {
- cmd = cmd.arg("--cookies-from-browser").arg(browser);
+ if !browser.is_empty() && browser != "none" {
+ cmd = cmd.arg("--cookies-from-browser").arg(ytdlp_cookie_browser_arg(browser));
}
}
@@ -2973,8 +2987,8 @@ async fn fetch_media_metadata_uncached(
.arg("%(.{title,duration,thumbnail,formats})j");
if let Some(browser) = cookie_browser.as_deref() {
- if !browser.is_empty() {
- cmd = cmd.arg("--cookies-from-browser").arg(browser);
+ if !browser.is_empty() && browser != "none" {
+ cmd = cmd.arg("--cookies-from-browser").arg(ytdlp_cookie_browser_arg(browser));
}
}
@@ -4048,6 +4062,7 @@ pub async fn rpc_call(
payload.insert("params".to_string(), serde_json::json!(p));
let client = reqwest::Client::builder()
+ .no_proxy()
.timeout(std::time::Duration::from_secs(3))
.build()
.map_err(|e| e.to_string())?;
@@ -4748,6 +4763,49 @@ fn normalize_media_cookie_source(source: Option<&str>) -> Result