Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/PROVIDER.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ would drift the first time the web app moved. Before registration the list is
empty and browser requests are refused, so it fails closed. Credentials are
never allowed: the token is in the query string, never a cookie.

Session installs and preloads check the filename's `.apk`/`.ipa` suffix against
the device's platform before staging or installing, returning a short HTTP 400
message for incompatible or unsupported file types.

Uploads stream to the scratch dir as they arrive, so a 200 MB APK never sits in
memory, and a `Drop` guard deletes the staged file however the install turns
out. A session install remains transient. A preload first copies the package to
Expand Down
4 changes: 4 additions & 0 deletions docs/WEB.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@ File listing and downloads go directly to the provider.
`DeviceConsole` is shared verbatim by `/devices/:id` and the popout, so there is
one control surface, not two that drift.

Install accepts `.apk` for Android and `.ipa` for iOS. The picker and drop hint
show the device's format; both picker selections and dropped files are checked
before uploading, with a short message explaining a platform mismatch.

### Controls: one grouped list, two renderings

The actions are built once as three sections — **Device** (Back, Home, Recents,
Expand Down
33 changes: 26 additions & 7 deletions packages/provider/crates/provider-core/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -811,14 +811,33 @@ async fn install_upload(
.map(sanitize_filename)
.unwrap_or_else(|| "upload.bin".to_owned());

if let Some(mode) = mode {
let Some(file_platform) = platform_for_filename(&filename) else {
return (
StatusCode::BAD_REQUEST,
"preload accepts only .apk or .ipa files",
)
.into_response();
let Some(file_platform) = platform_for_filename(&filename) else {
return (StatusCode::BAD_REQUEST, "Choose an .apk or .ipa file.").into_response();
};
let Some(info) = device.info().await else {
return (
StatusCode::SERVICE_UNAVAILABLE,
"Device is unavailable. Try again when it is ready.",
)
.into_response();
};
let platform = match info.platform {
yard_protocol::Platform::Android => "android",
yard_protocol::Platform::Ios => "ios",
};
if file_platform != platform {
let error = match info.platform {
yard_protocol::Platform::Android => {
"Android devices require an .apk file. .ipa files are for iOS."
}
yard_protocol::Platform::Ios => {
"iOS devices require an .ipa file. .apk files are for Android."
}
};
return (StatusCode::BAD_REQUEST, error).into_response();
}

if let Some(mode) = mode {
if claims.platform.as_deref() != Some(file_platform) {
return (
StatusCode::BAD_REQUEST,
Expand Down
114 changes: 87 additions & 27 deletions packages/provider/crates/provider-core/tests/session_plane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -578,34 +578,94 @@ async fn the_file_routes_refuse_the_same_tokens_everything_else_does() {
#[tokio::test]
async fn upload_installs_then_deletes_the_staged_file() {
let h = start().await;
h.authorize().await;

let payload = vec![7u8; 512 * 1024];
let res = reqwest::Client::new()
.post(format!(
"{}/s/{DEVICE_ID}/install?token={}",
h.base,
h.token()
))
.header("x-yard-filename", "app-release.apk")
.body(payload)
.send()
.await
.unwrap();
for (device_id, filename) in [
(DEVICE_ID, "app-release.ipa"),
(DEVICE_ID, "app-release.IPA"),
(OTHER_DEVICE, "app-release.apk"),
(OTHER_DEVICE, "app-release.APK"),
] {
h.sessions
.authorize(
device_id,
Authorization {
reservation_id: RESERVATION.into(),
user_id: "user-1".into(),
adb_keys: Vec::new(),
},
)
.await;
let token = h.signer.token(&h.issuer, device_id, RESERVATION, 60);
let payload = vec![7u8; 512 * 1024];
let res = reqwest::Client::new()
.post(format!("{}/s/{device_id}/install?token={token}", h.base))
.header("x-yard-filename", filename)
.body(payload)
.send()
.await
.unwrap();

assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["ok"], true);
assert_eq!(body["size"], 512 * 1024);
assert_eq!(body["filename"], "app-release.apk");
assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["ok"], true);
assert_eq!(body["size"], 512 * 1024);
assert_eq!(body["filename"], filename);

// Nothing is stored server-side; a leaked staged file on a tmpfs scratch
// dir is how a provider host runs out of space.
assert!(
h.scratch_files().is_empty(),
"staged upload was left behind: {:?}",
h.scratch_files()
);
// Nothing is stored server-side; a leaked staged file on a tmpfs scratch
// dir is how a provider host runs out of space.
assert!(
h.scratch_files().is_empty(),
"staged upload was left behind: {:?}",
h.scratch_files()
);
}
}

#[tokio::test]
async fn upload_rejects_incompatible_packages_before_writing_or_installing() {
let h = start().await;
std::fs::remove_dir(&h.scratch).unwrap();
for (device_id, filename, error) in [
(
OTHER_DEVICE,
"release.ipa",
"Android devices require an .apk file. .ipa files are for iOS.",
),
(
DEVICE_ID,
"release.APK",
"iOS devices require an .ipa file. .apk files are for Android.",
),
(OTHER_DEVICE, "release.zip", "Choose an .apk or .ipa file."),
(DEVICE_ID, "release.ipa.zip", "Choose an .apk or .ipa file."),
] {
h.sessions
.authorize(
device_id,
Authorization {
reservation_id: RESERVATION.into(),
user_id: "user-1".into(),
adb_keys: Vec::new(),
},
)
.await;
let token = h.signer.token(&h.issuer, device_id, RESERVATION, 60);
let res = reqwest::Client::new()
.post(format!("{}/s/{device_id}/install?token={token}", h.base))
.header("x-yard-filename", filename)
.body(vec![3u8; 1024])
.send()
.await
.unwrap();

assert_eq!(res.status(), 400, "{filename}");
assert_eq!(res.text().await.unwrap(), error);
assert!(!h.scratch.exists());
assert_eq!(
h.supervisor.device(device_id).unwrap().install_counts(),
(0, 0)
);
}
}

#[tokio::test]
Expand Down Expand Up @@ -820,15 +880,15 @@ async fn a_hostile_upload_filename_cannot_escape_the_scratch_directory() {
h.base,
h.token()
))
.header("x-yard-filename", "../../../../tmp/pwned.apk")
.header("x-yard-filename", "../../../../tmp/pwned.ipa")
.body(vec![0u8; 1024])
.send()
.await
.unwrap();

assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["filename"], "pwned.apk");
assert_eq!(body["filename"], "pwned.ipa");
assert!(h.scratch_files().is_empty());
}

Expand Down
14 changes: 11 additions & 3 deletions packages/web/src/components/device-console.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMutation } from "@tanstack/react-query";
import type { Platform } from "@yard/protocol";
import {
Camera,
ChevronLeft,
Expand Down Expand Up @@ -43,6 +44,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useDeviceSession } from "@/hooks/use-device-session";
import { appInstallError } from "@/lib/app-package";
import {
type Corner,
controlsAxis,
Expand Down Expand Up @@ -104,7 +106,7 @@ export function DeviceConsole({
* Which hardware buttons exist, and how to phrase what the file browser can
* reach. iOS has a Home button and nothing else — no Back, no Recents.
*/
platform?: string;
platform: Platform;
/** False when the device is not reserved by this user — no session is opened. */
active: boolean;
admin?: boolean;
Expand Down Expand Up @@ -204,6 +206,11 @@ export function DeviceConsole({
};

const upload = async (file: File) => {
const error = appInstallError(file.name, platform);
if (error) {
toast.error(error);
return;
}
setInstall({ name: file.name, fraction: 0 });
try {
await installApp(deviceId, file, (fraction) =>
Expand Down Expand Up @@ -657,7 +664,8 @@ export function DeviceConsole({
{dragging && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md border-2 border-primary border-dashed bg-background/80 text-sm">
<span className="flex items-center gap-2">
<Upload className="size-4" /> Drop an APK or IPA to install
<Upload className="size-4" /> Drop {platform === "android" ? "an APK" : "an IPA"} to
install
</span>
</div>
)}
Expand All @@ -684,7 +692,7 @@ export function DeviceConsole({
<input
ref={installInput}
type="file"
accept=".apk,.ipa"
accept={platform === "android" ? ".apk" : ".ipa"}
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
Expand Down
15 changes: 15 additions & 0 deletions packages/web/src/lib/app-package.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Platform } from "@yard/protocol";

export function appInstallError(filename: string, platform: Platform): string | null {
const lower = filename.toLowerCase();
if (!lower.endsWith(".apk") && !lower.endsWith(".ipa")) {
return "Choose an .apk or .ipa file.";
}
if (platform === "android" && !lower.endsWith(".apk")) {
return "Android devices require an .apk file. .ipa files are for iOS.";
}
if (platform === "ios" && !lower.endsWith(".ipa")) {
return "iOS devices require an .ipa file. .apk files are for Android.";
}
return null;
}
27 changes: 27 additions & 0 deletions packages/web/test/app-package.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { expect, test } from "bun:test";
import { appInstallError } from "../src/lib/app-package";

test("wrong-platform packages explain which file to choose", () => {
expect(appInstallError("release.ipa", "android")).toBe(
"Android devices require an .apk file. .ipa files are for iOS.",
);
expect(appInstallError("release.apk", "ios")).toBe(
"iOS devices require an .ipa file. .apk files are for Android.",
);
});

test("matching packages accept either extension case", () => {
for (const [filename, platform] of [
["release.apk", "android"],
["release.APK", "android"],
["release.ipa", "ios"],
["release.IPA", "ios"],
] as const) {
expect(appInstallError(filename, platform)).toBeNull();
}
});

test("non-package files are rejected on both platforms", () => {
expect(appInstallError("release.zip", "ios")).toBe("Choose an .apk or .ipa file.");
expect(appInstallError("release.apk.zip", "android")).toBe("Choose an .apk or .ipa file.");
});
Loading