Skip to content

Commit 00b8f18

Browse files
committed
chore(release): prepare v0.3.6 - market-readiness pass
- Privacy: remove agent_debug_log + hardcoded host-side path from sftp.rs so SFTP debugging no longer writes hostnames, IPs, or key fingerprints to disk. - Security: restrictive CSP in tauri.conf.json (default-src 'self', IPC + ws/wss for terminals and the Proxmox WebSocket proxy); SECURITY.md updated. - CI: extend ci.yml with tsc + Vitest, cargo check + cargo test, and advisory cargo fmt / clippy jobs alongside the existing Playwright job. - Robustness: replace unwrap()/expect() in mutex paths with graceful recovery in sftp_transfer_ops.rs and proxmux_ws_proxy.rs. - Tests: add unit-test coverage for sftp.rs (host-key candidates, path normalization, error classification), sftp_transfer_ops.rs (handle registration / release), session.rs (SSH path / known-hosts helpers), license.rs (server-token round-trip + tamper detection), and the license-server crate (token signing + entitlement parsing). - Performance: wrap TerminalPane in React.memo; extract useSettingsModalDrag hook from App.tsx; document hot paths and the SFTP measurement plan in docs/performance.md. - Release plumbing: opt-in macOS code signing + notarization and Windows code signing wired into release.yml; docs/releases.md lists the required GitHub secrets. - Store packaging: AppStream metainfo refreshed to Flathub validation level (description, screenshots, categories, keywords, release history); new docs/store-packaging.md documents the GitHub / AUR / Flathub / MS Store / Snap channels. - Version bump: 0.3.6 across package.json, package-lock.json, Cargo.toml, tauri.conf.json, AUR PKGBUILD, and the AppStream <releases> block; CHANGELOG and README/releases.md tag examples updated.
1 parent f72acfe commit 00b8f18

28 files changed

Lines changed: 1291 additions & 406 deletions

.github/ISSUE_TEMPLATE/bug_report.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ labels: bug
66

77
## Environment
88

9-
- NoSuckShell version (or commit), e.g. `0.3.6-beta.1` or `git rev-parse HEAD`:
9+
- NoSuckShell version (or commit), e.g. `0.3.6` or `git rev-parse HEAD`:
1010
- OS / distro (e.g. CachyOS, macOS 14, Windows 11):
1111
- If Linux WebKit issues: Wayland sessions set `WEBKIT_DISABLE_DMABUF_RENDERER` by default when unset; did you try setting or unsetting it explicitly? (yes/no / N/A)
1212

.github/workflows/ci.yml

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,116 @@ concurrency:
1717
cancel-in-progress: true
1818

1919
jobs:
20+
desktop-frontend:
21+
name: Desktop frontend (typecheck + unit tests)
22+
runs-on: ubuntu-latest
23+
steps:
24+
- name: Checkout
25+
uses: actions/checkout@v6
26+
27+
- name: Setup Node.js
28+
uses: actions/setup-node@v6
29+
with:
30+
node-version: 22
31+
cache: npm
32+
cache-dependency-path: apps/desktop/package-lock.json
33+
34+
- name: Install desktop dependencies
35+
run: npm run desktop:install
36+
37+
- name: TypeScript build (tsc + vite build)
38+
working-directory: apps/desktop
39+
run: npm run build
40+
41+
- name: Vitest unit tests
42+
working-directory: apps/desktop
43+
run: npm test
44+
45+
desktop-rust:
46+
name: Desktop backend (cargo check + cargo test)
47+
runs-on: ubuntu-latest
48+
steps:
49+
- name: Checkout
50+
uses: actions/checkout@v6
51+
52+
- name: Setup Rust toolchain
53+
uses: dtolnay/rust-toolchain@stable
54+
55+
- name: Install Linux dependencies
56+
shell: bash
57+
run: |
58+
sudo apt-get update
59+
sudo apt-get install -y \
60+
libwebkit2gtk-4.1-dev \
61+
libgtk-3-dev \
62+
libayatana-appindicator3-dev \
63+
librsvg2-dev \
64+
patchelf
65+
66+
- name: Cache Cargo registry and target
67+
uses: actions/cache@v4
68+
with:
69+
path: |
70+
~/.cargo/registry
71+
~/.cargo/git
72+
apps/desktop/src-tauri/target
73+
key: ${{ runner.os }}-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
74+
restore-keys: |
75+
${{ runner.os }}-cargo-
76+
77+
- name: cargo check
78+
working-directory: apps/desktop/src-tauri
79+
run: cargo check --all-targets
80+
81+
- name: cargo test
82+
working-directory: apps/desktop/src-tauri
83+
run: cargo test
84+
85+
desktop-rust-quality:
86+
name: Desktop backend (rustfmt + clippy, advisory)
87+
runs-on: ubuntu-latest
88+
# Advisory until the rust-error-audit cleanup lands; the job runs but does not block merges.
89+
continue-on-error: true
90+
steps:
91+
- name: Checkout
92+
uses: actions/checkout@v6
93+
94+
- name: Setup Rust toolchain
95+
uses: dtolnay/rust-toolchain@stable
96+
with:
97+
components: rustfmt, clippy
98+
99+
- name: Install Linux dependencies
100+
shell: bash
101+
run: |
102+
sudo apt-get update
103+
sudo apt-get install -y \
104+
libwebkit2gtk-4.1-dev \
105+
libgtk-3-dev \
106+
libayatana-appindicator3-dev \
107+
librsvg2-dev \
108+
patchelf
109+
110+
- name: Cache Cargo registry and target
111+
uses: actions/cache@v4
112+
with:
113+
path: |
114+
~/.cargo/registry
115+
~/.cargo/git
116+
apps/desktop/src-tauri/target
117+
key: ${{ runner.os }}-cargo-quality-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
118+
restore-keys: |
119+
${{ runner.os }}-cargo-quality-
120+
${{ runner.os }}-cargo-
121+
122+
- name: cargo fmt --check
123+
working-directory: apps/desktop/src-tauri
124+
run: cargo fmt --all --check
125+
126+
- name: cargo clippy
127+
working-directory: apps/desktop/src-tauri
128+
run: cargo clippy --all-targets
129+
20130
desktop-e2e:
21131
name: Desktop E2E (Playwright)
22132
runs-on: ubuntu-latest

.github/workflows/release.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,13 +263,62 @@ jobs:
263263
working-directory: apps/desktop
264264
run: npx @tauri-apps/cli icon src-tauri/icons/icon-source-square.png --output src-tauri/icons
265265

266+
# Import Apple Developer ID certificate into a temporary keychain (macOS only).
267+
# Requires repository secrets:
268+
# - APPLE_CERTIFICATE base64-encoded `.p12` of the Developer ID Application cert
269+
# - APPLE_CERTIFICATE_PASSWORD password used when exporting the .p12
270+
# - APPLE_SIGNING_IDENTITY full identity string, e.g. "Developer ID Application: Name (TEAMID)"
271+
# Skipped (warns only) when secrets are missing so non-official forks can still build.
272+
- name: Import Apple signing certificate
273+
if: matrix.artifact_name == 'macos'
274+
env:
275+
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
276+
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
277+
shell: bash
278+
run: |
279+
set -euo pipefail
280+
if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then
281+
echo "::warning::APPLE_CERTIFICATE secret is unset — skipping macOS code signing."
282+
exit 0
283+
fi
284+
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
285+
KEYCHAIN_PASSWORD="$(uuidgen)"
286+
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
287+
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
288+
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
289+
echo "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12"
290+
security import "$RUNNER_TEMP/cert.p12" \
291+
-k "$KEYCHAIN_PATH" \
292+
-P "$APPLE_CERTIFICATE_PASSWORD" \
293+
-T /usr/bin/codesign
294+
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null
295+
security list-keychain -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"')
296+
266297
# Embeds production Ed25519 verify key at compile time (see license.rs `option_env!`).
267298
# Repository secret: 64 hex chars (32-byte public key), same as desktop `NOSUCKSHELL_LICENSE_PUBKEY_HEX`.
268299
# If unset, binaries fall back to the public dev key (fine for forks; official releases should set the secret).
300+
#
301+
# Code signing / notarization secrets that Tauri reads automatically when present:
302+
# macOS APPLE_SIGNING_IDENTITY Developer ID Application identity string
303+
# macOS APPLE_ID + APPLE_PASSWORD App-Store-Connect account + app-specific password
304+
# macOS APPLE_TEAM_ID Apple Developer team ID (e.g. ABCDE12345)
305+
# macOS APPLE_API_KEY/...PATH/...ISSUER Alternative to APPLE_ID for App-Store-Connect API
306+
# Windows TAURI_WINDOWS_SIGN_COMMAND Full sign command (e.g. AzureSignTool / signtool)
307+
#
308+
# All are optional. Tauri skips signing/notarization cleanly when the matching variables are unset
309+
# (warns in the build log) so this workflow keeps producing usable binaries before certs land.
269310
- name: Build Tauri app bundles
270311
working-directory: apps/desktop
271312
env:
272313
NOSUCKSHELL_LICENSE_PUBKEY_HEX: ${{ secrets.NOSUCKSHELL_LICENSE_PUBKEY_HEX }}
314+
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
315+
APPLE_ID: ${{ secrets.APPLE_ID }}
316+
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
317+
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
318+
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
319+
APPLE_API_KEY_PATH: ${{ secrets.APPLE_API_KEY_PATH }}
320+
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
321+
TAURI_WINDOWS_SIGN_COMMAND: ${{ secrets.TAURI_WINDOWS_SIGN_COMMAND }}
273322
run: npm run tauri:build
274323

275324
- name: Post-process Linux AppImage (strip bundled libpcre2-8)

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -188,10 +188,10 @@ Details: [docs/backup-security.md](docs/backup-security.md)
188188

189189
GitHub releases are created by pushing a SemVer tag. Full checklist: [docs/releases.md](docs/releases.md). User-facing history: [docs/CHANGELOG.md](docs/CHANGELOG.md).
190190

191-
- Final: `vMAJOR.MINOR.PATCH` (example: `v0.2.1`, `v1.2.3`)
192-
- Pre-release: `vMAJOR.MINOR.PATCH-<suffix>` (example: `v0.3.6-beta.1`, `v1.2.4-rc.1`)
191+
- Final: `vMAJOR.MINOR.PATCH` (example: `v0.3.6`, `v1.2.3`)
192+
- Pre-release: `vMAJOR.MINOR.PATCH-<suffix>` (example: `v0.3.7-beta.1`, `v1.2.4-rc.1`)
193193

194-
**Current pre-release:** [`v0.3.6-beta.1`](https://github.com/d0dg3r/NoSuckShell/releases/tag/v0.3.6-beta.1)Linux terminal input latency improvements (see [CHANGELOG](docs/CHANGELOG.md)).
194+
**Current stable:** [`v0.3.6`](https://github.com/d0dg3r/NoSuckShell/releases/tag/v0.3.6)first store-ready cut of the `0.3.x` line (see [CHANGELOG](docs/CHANGELOG.md)).
195195

196196
**Before tagging**, use the same version string in:
197197

@@ -202,8 +202,8 @@ GitHub releases are created by pushing a SemVer tag. Full checklist: [docs/relea
202202
The [release workflow](.github/workflows/release.yml) still overwrites those files from the tag at build time; keeping them aligned locally avoids drift while developing.
203203

204204
```bash
205-
git tag v0.2.1
206-
git push origin v0.2.1
205+
git tag v0.3.6
206+
git push origin v0.3.6
207207
```
208208

209209
If the workflow rejects the tag, use `vMAJOR.MINOR.PATCH` or `vMAJOR.MINOR.PATCH-prerelease` (example: `v2.0.0` or `v2.0.0-rc.1`).

SECURITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ The app may process **highly sensitive** data. You should:
8484

8585
## Technical notes (transparency)
8686

87-
- Tauri config currently sets **`csp: null`** for the webview — this affects **Content Security Policy** for the embedded frontend. Consult the [Tauri documentation](https://v2.tauri.app/) and the source when assessing impact.
87+
- The Tauri webview enforces a **Content Security Policy** (see `app.security.csp` in [`apps/desktop/src-tauri/tauri.conf.json`](apps/desktop/src-tauri/tauri.conf.json)). It blocks inline scripts, restricts `object-src` to `'none'`, and limits `base-uri`/`form-action` to `'self'`. It still allows arbitrary `https:`/`http:` for `frame-src`/`img-src`/`connect-src` because the app embeds Proxmox Web UIs and connects to user-configured Proxmox WebSocket consoles (often via self-signed TLS). React's inline `style={{...}}` props require `style-src 'unsafe-inline'`. Tightening these to the minimum set per session is tracked as a follow-up.
8888
- **Dependencies** (Rust, npm) have their own security posture; staying current and running `cargo audit` / `npm audit` is part of responsible use.
8989

9090
---

apps/desktop/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "desktop",
3-
"version": "0.3.6-beta.1",
3+
"version": "0.3.6",
44
"description": "Cross-platform SSH manager desktop app",
55
"scripts": {
66
"postinstall": "patch-package",

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "src-tauri"
3-
version = "0.3.6-beta.1"
3+
version = "0.3.6"
44
edition = "2024"
55
license = "MIT"
66
build = "build.rs"

apps/desktop/src-tauri/src/license.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,4 +290,75 @@ mod tests {
290290
assert_eq!(mode, "fused_first_last");
291291
assert_eq!(n, "aaaa.dddd");
292292
}
293+
294+
/// End-to-end: build the same `base64url(payload).base64url(sig)` token format the
295+
/// `services/license-server` emits and confirm the desktop's verifier accepts it. Catches
296+
/// drift between the server and desktop `LicensePayload` serialization (field order,
297+
/// `serde(rename_all)`, `skip_serializing_if`).
298+
#[test]
299+
fn end_to_end_server_token_format_verifies_on_desktop() {
300+
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
301+
use base64::Engine;
302+
303+
let sk = SigningKey::from_bytes(&DEV_LICENSE_SEED);
304+
let payload = LicensePayload {
305+
v: 1,
306+
license_id: "e2e-1".into(),
307+
entitlements: vec![
308+
"dev.nosuckshell.tier.demo".into(),
309+
"dev.nosuckshell.plugin.proxmux".into(),
310+
],
311+
iat: now_unix(),
312+
exp: None,
313+
};
314+
315+
// Server side: sign UTF-8 JSON of the payload, encode payload + signature as base64url.
316+
let json = serde_json::to_string(&payload).expect("serialize payload");
317+
let sig = sk.sign(json.as_bytes());
318+
let token = format!(
319+
"{}.{}",
320+
URL_SAFE_NO_PAD.encode(json.as_bytes()),
321+
URL_SAFE_NO_PAD.encode(sig.to_bytes()),
322+
);
323+
324+
// Desktop side: split, decode, parse, hex-encode signature, and verify.
325+
let (payload_b64, sig_b64) = token.split_once('.').expect("token has two parts");
326+
let decoded_payload = URL_SAFE_NO_PAD
327+
.decode(payload_b64.as_bytes())
328+
.expect("payload base64url decodes");
329+
let decoded_sig = URL_SAFE_NO_PAD
330+
.decode(sig_b64.as_bytes())
331+
.expect("signature base64url decodes");
332+
assert_eq!(
333+
decoded_sig.len(),
334+
64,
335+
"Ed25519 signature must be exactly 64 bytes"
336+
);
337+
let parsed: LicensePayload =
338+
serde_json::from_slice(&decoded_payload).expect("payload JSON parses");
339+
assert_eq!(parsed, payload, "round-trip payload must equal source");
340+
341+
verify_payload(&parsed, &hex::encode(&decoded_sig)).expect("signature must verify");
342+
}
343+
344+
#[test]
345+
fn rejects_tampered_payload_with_valid_signature() {
346+
let sk = SigningKey::from_bytes(&DEV_LICENSE_SEED);
347+
let original = LicensePayload {
348+
v: 1,
349+
license_id: "tamper-1".into(),
350+
entitlements: vec!["dev.nosuckshell.tier.demo".into()],
351+
iat: now_unix(),
352+
exp: None,
353+
};
354+
let msg = license_message(&original).unwrap();
355+
let sig_hex = hex::encode(sk.sign(msg.as_bytes()).to_bytes());
356+
357+
// Adding an extra entitlement after signing must fail verification.
358+
let mut tampered = original;
359+
tampered
360+
.entitlements
361+
.push("dev.nosuckshell.plugin.proxmux".into());
362+
assert!(verify_payload(&tampered, &sig_hex).is_err());
363+
}
293364
}

apps/desktop/src-tauri/src/proxmux_ws_proxy.rs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,12 @@ async fn proxy_one_browser_connection(
111111
let err = http::Response::builder()
112112
.status(http::StatusCode::FORBIDDEN)
113113
.body(Some("Forbidden: invalid proxy token".to_string()))
114-
.expect("response");
114+
.unwrap_or_else(|_| {
115+
// The body above is a static String; this branch is unreachable in practice.
116+
let mut fallback = http::Response::new(None);
117+
*fallback.status_mut() = http::StatusCode::FORBIDDEN;
118+
fallback
119+
});
115120
return Err(err);
116121
}
117122
Ok(response)
@@ -232,7 +237,9 @@ pub async fn proxmux_ws_proxy_start(
232237
auth,
233238
cookie,
234239
));
235-
let mut guard = conn_handles_for_loop.lock().expect("conn handles lock");
240+
let mut guard = conn_handles_for_loop
241+
.lock()
242+
.unwrap_or_else(|e| e.into_inner());
236243
guard.retain(|h| !h.is_finished());
237244
guard.push(conn_handle);
238245
}
@@ -246,7 +253,7 @@ pub async fn proxmux_ws_proxy_start(
246253

247254
proxy_tasks()
248255
.lock()
249-
.expect("proxy map lock")
256+
.unwrap_or_else(|e| e.into_inner())
250257
.insert(proxy_id.clone(), ProxyEntry { accept_loop, conn_handles });
251258

252259
Ok(ProxmuxWsProxyStartResult {
@@ -261,9 +268,18 @@ pub fn proxmux_ws_proxy_stop(proxy_id: String) -> Result<(), String> {
261268
if id.is_empty() {
262269
return Err("proxyId is required".to_string());
263270
}
264-
if let Some(entry) = proxy_tasks().lock().expect("proxy map lock").remove(&id) {
271+
let removed = proxy_tasks()
272+
.lock()
273+
.unwrap_or_else(|e| e.into_inner())
274+
.remove(&id);
275+
if let Some(entry) = removed {
265276
entry.accept_loop.abort();
266-
for h in entry.conn_handles.lock().expect("conn handles lock").drain(..) {
277+
for h in entry
278+
.conn_handles
279+
.lock()
280+
.unwrap_or_else(|e| e.into_inner())
281+
.drain(..)
282+
{
267283
h.abort();
268284
}
269285
}

0 commit comments

Comments
 (0)