feat(installer): implement Phase 2 — uo-link install and service
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 1m15s
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 1m15s
Adds the sidecar half of a deployment to the same `install` run: download and verify the bundle's binary, provision its config, register and start a service, and print the token handoff PLAN.md §6 specifies. `src/sidecar.rs` owns the binary and the config document; `src/service.rs` owns systemd and the Windows SCM. The order is fixed by PLAN.md §5 and matters: stop anything running the old binary, replace it, then `--print-config` (which writes the config the service will be pointed at), then register. Registering first points a service at a file that does not exist yet. Decisions worth a reviewer's attention: - Both platforms run the sidecar as a dedicated unprivileged identity. Linux gets the `runicgateway` system user the plan already specified; Windows gets a virtual service account, `sc create ... obj= "NT SERVICE\RunicGatewayLink"`, which the SCM creates itself and which has no password. Plain `sc create` runs as LocalSystem — the most privileged local identity there is, for a process listening on two TCP ports while its Linux twin deliberately does not run as root. - `sidecar.toml` holds the auth token and neither default location protects it: /etc is world-readable and %ProgramData% grants Users read by inheritance, so a stock install would leave the shard's token readable by any local account. The lockdown straddles registration because it has to — on Windows the service account does not exist until `sc create` creates it, so the file is first cut down to SYSTEM + Administrators, and the account's read grant comes after. - Only Linux pins UOLINK_DB_PATH. On Windows config and data share a directory and the sidecar anchors a relative [store] path to its config's directory, so the pin is redundant — and `sc.exe` has no per-service environment, only a machine-wide one that every process inherits and that outlives an uninstall. The config path rides in the service's own binPath instead. - `--verify` runs no part of the sidecar half. `--print-config` provisions: it writes the config and mints a token, so a dry run that called it would create the state it claims not to. It also carries an existing `link` section of install.json through untouched, so a dry run cannot make a service disappear from the record. - The installed binary's protocol version is checked against the bundle before the service is registered. Gate 1 read that number from source at the release tag; this is the same check applied to the binary that will actually answer the website. - RUNICGATEWAY_STATE_DIR now relocates the sidecar binary as well, and suppresses service registration and the file-permission hardening. There is no such thing as a relocated systemd unit, and hardening a scratch config against the only account that will ever read it just breaks the next test run. - A host with no systemd, or where the service user cannot be created, still gets a working binary and config plus the exact unit and commands. There is no fallback to User=root or LocalSystem: a service quietly running with more privilege than its documentation promises is worse than one that was not registered. - install.json never records the token. The `link` section carries versions, the binary's hash, the config and database paths, and the service's name, unit path and account. Docs half: docs#91. Tested: cargo fmt --check, clippy --all-targets -D warnings, 72 tests. End to end on Windows against a relocated layout — bundle sidecar downloaded and verified, config provisioned, handoff printed with URLs composed from the host rather than the bind address, second run reporting unchanged with install.json byte-identical, --verify over an installed host writing nothing and preserving the link section, and a tampered binary detected by hash and replaced with no staging file left. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
465
src/sidecar.rs
Normal file
465
src/sidecar.rs
Normal file
@@ -0,0 +1,465 @@
|
||||
//! The uo-link sidecar: install the binary, provision its config, read the token back.
|
||||
//!
|
||||
//! This is the half of the deployment that makes the website work at all. The overlay puts code in
|
||||
//! the ServUO tree; nothing reaches a website until a sidecar is listening on `127.0.0.1:7788` for
|
||||
//! the shard to dial out to, and until the website has been given its address, protocol version and
|
||||
//! token (PLAN.md §2.4 calls that missing handoff the largest "I installed it and nothing happened"
|
||||
//! failure mode).
|
||||
//!
|
||||
//! Three rules govern this module:
|
||||
//!
|
||||
//! - **Every value in the handoff comes from asking the installed binary**, via
|
||||
//! `--print-config --config <the pinned path>`. Not from the log, not from re-reading the TOML,
|
||||
//! and not from the installer's own idea of what it wrote. That single call also *provisions* —
|
||||
//! it writes the config file if absent and generates the token if blank — which is why PLAN.md
|
||||
//! §5 requires it to run **before** the service is registered: the service must never start
|
||||
//! against a config that does not exist yet.
|
||||
//! - **The token is printed and never stored.** It goes to the operator's terminal and into
|
||||
//! `sidecar.toml`, and nowhere else — not into `install.json`, not into an error message, not
|
||||
//! into the output of a failed command (PLAN.md §6). That is why `--print-config` is run through
|
||||
//! [`crate::util::run`] and handled here rather than through `run_ok`, which quotes what a
|
||||
//! command printed.
|
||||
//! - **A binary in place is not a working sidecar.** Nothing here claims more than "the file is
|
||||
//! installed and it answered `--print-config`"; whether the shard ever dials in is `doctor`'s
|
||||
//! question (Phase 4).
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::bundle::Asset;
|
||||
use crate::util::{run, sha256_file};
|
||||
|
||||
/// The document `uo-link-sidecar --print-config` prints (`link/sidecar/src/config.rs::describe`).
|
||||
///
|
||||
/// Unknown fields are ignored on purpose: a newer sidecar that adds a key must not break an
|
||||
/// installer that does not know about it, and every field read here has been in the document since
|
||||
/// the CLI was introduced in link v1.1.0.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ConfigDoc {
|
||||
pub component: String,
|
||||
pub version: String,
|
||||
pub protocol: u32,
|
||||
pub config_path: String,
|
||||
/// This run created the config file. False on every re-run — which is how the installer knows
|
||||
/// not to report a token as newly minted when it is simply being read back.
|
||||
pub config_created: bool,
|
||||
pub token_generated: bool,
|
||||
pub shard: ShardDoc,
|
||||
pub web: WebDoc,
|
||||
pub store: StoreDoc,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ShardDoc {
|
||||
pub bind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct WebDoc {
|
||||
pub bind: String,
|
||||
pub ws_path: String,
|
||||
pub auth_required: bool,
|
||||
/// **A secret.** Printed in the handoff block and never recorded anywhere else.
|
||||
pub auth_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct StoreDoc {
|
||||
/// Absolute, already resolved by the sidecar against its config file's directory.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// What installing the binary would do, decided by hash before anything is downloaded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BinaryAction {
|
||||
/// Nothing is installed at the target path yet.
|
||||
Install,
|
||||
/// Something is, and it is not what the bundle names.
|
||||
Replace,
|
||||
/// The bundle's binary is already in place, byte for byte.
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
impl BinaryAction {
|
||||
pub fn writes(self) -> bool {
|
||||
!matches!(self, Self::Unchanged)
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Install => "install",
|
||||
Self::Replace => "replace",
|
||||
Self::Unchanged => "unchanged",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares what is installed against what the bundle names.
|
||||
///
|
||||
/// Hash rather than version string: the version an installed binary reports costs a process launch
|
||||
/// to obtain and would still not distinguish two builds of the same version. The bundle records the
|
||||
/// SHA256 CI computed from the asset it verified (PLAN.md §7.1 gate 2), so this comparison is
|
||||
/// against the same value the download will be checked against.
|
||||
pub fn decide(asset: &Asset, dest: &Path) -> Result<BinaryAction> {
|
||||
if !dest.exists() {
|
||||
return Ok(BinaryAction::Install);
|
||||
}
|
||||
let installed = sha256_file(dest)?;
|
||||
if installed.eq_ignore_ascii_case(asset.sha256.trim()) {
|
||||
Ok(BinaryAction::Unchanged)
|
||||
} else {
|
||||
Ok(BinaryAction::Replace)
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads the sidecar binary and puts it at `dest`, executable.
|
||||
///
|
||||
/// The download lands in the scratch directory and is checksum-verified there, then copied to a
|
||||
/// `.new` sibling of the target and renamed over it. The two-step matters on both platforms for
|
||||
/// different reasons: on Windows the target is locked while the service runs (the caller stops it
|
||||
/// first), and on either, a copy interrupted halfway would otherwise leave a truncated binary at
|
||||
/// exactly the path a service is about to execute.
|
||||
pub fn place(asset: &Asset, dest: &Path, scratch: &Path) -> Result<String> {
|
||||
let staged = scratch.join(&asset.name);
|
||||
crate::net::download_verified(&asset.url, &staged, &asset.sha256)?;
|
||||
|
||||
let parent = dest
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("{} has no parent directory", dest.display()))?;
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"cannot create {} — run as root/Administrator",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let pending = pending_path(dest);
|
||||
fs::copy(&staged, &pending).with_context(|| format!("cannot write {}", pending.display()))?;
|
||||
set_executable(&pending)?;
|
||||
|
||||
// Windows will not rename onto an existing file. The old binary goes first; the replacement is
|
||||
// already complete on disk by this point, so the window is a rename wide.
|
||||
if dest.exists() {
|
||||
fs::remove_file(dest).with_context(|| {
|
||||
format!(
|
||||
"cannot replace {} — if a service is running it, stop it first",
|
||||
dest.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs::rename(&pending, dest)
|
||||
.with_context(|| format!("cannot move {} into place", pending.display()))?;
|
||||
|
||||
sha256_file(dest)
|
||||
}
|
||||
|
||||
/// `uo-link-sidecar.exe` → `uo-link-sidecar.new`, in the same directory as the target.
|
||||
///
|
||||
/// Same directory so the final step is a rename rather than a cross-filesystem copy: `/tmp` and
|
||||
/// `/usr/bin` are routinely different mounts, and a rename between them fails.
|
||||
fn pending_path(dest: &Path) -> PathBuf {
|
||||
let mut name = dest.file_name().unwrap_or_default().to_os_string();
|
||||
name.push(".new");
|
||||
dest.with_file_name(name)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_executable(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
|
||||
.with_context(|| format!("cannot make {} executable", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_executable(_path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs the installed binary's `--print-config`, provisioning the config and returning the token.
|
||||
///
|
||||
/// `db_path` is passed as `UOLINK_DB_PATH` **only when it is not already where the sidecar would
|
||||
/// put it** — that is, on Linux, where the config lives in `/etc` and the database in `/var/lib`.
|
||||
/// On Windows both are `%ProgramData%\RunicGateway`, the sidecar anchors a relative `[store].path`
|
||||
/// to its config's directory, and passing the variable would buy nothing while implying the service
|
||||
/// needs a machine-wide environment variable it does not (see [`crate::paths`]).
|
||||
///
|
||||
/// **This function must not print, log or attach the child's stdout to an error.** It is the one
|
||||
/// place in the installer where a secret crosses a process boundary.
|
||||
pub fn print_config(binary: &Path, config: &Path, db_path: Option<&Path>) -> Result<ConfigDoc> {
|
||||
let mut command = std::process::Command::new(binary);
|
||||
command.arg("--print-config").arg("--config").arg(config);
|
||||
if let Some(db) = db_path {
|
||||
command.env("UOLINK_DB_PATH", db);
|
||||
}
|
||||
|
||||
let output = command.output().with_context(|| {
|
||||
format!(
|
||||
"cannot run {} --print-config. The binary was just installed, so this usually means it \
|
||||
cannot execute here — a 32/64-bit or libc mismatch, or a filesystem mounted noexec.",
|
||||
binary.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
// stderr only. stdout is the document, and the document contains the auth token.
|
||||
let reason = String::from_utf8_lossy(&output.stderr)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.unwrap_or("(nothing on stderr)")
|
||||
.to_string();
|
||||
bail!(
|
||||
"{} --print-config --config {} failed with {}: {reason}",
|
||||
binary.display(),
|
||||
config.display(),
|
||||
match output.status.code() {
|
||||
Some(code) => format!("exit code {code}"),
|
||||
None => "no exit code".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let doc: ConfigDoc = serde_json::from_slice(&output.stdout).context(
|
||||
"the sidecar's --print-config output is not the document this installer expects. \
|
||||
Its contents are not shown here because they would contain the auth token; run the same \
|
||||
command by hand to see it (INSTALL.md Appendix A3).",
|
||||
)?;
|
||||
|
||||
if doc.component != "uo-link-sidecar" {
|
||||
bail!(
|
||||
"the binary at {} identifies itself as {:?}, not uo-link-sidecar",
|
||||
binary.display(),
|
||||
doc.component
|
||||
);
|
||||
}
|
||||
if doc.web.auth_token.trim().is_empty() {
|
||||
// Authentication is always on in the sidecar, so this cannot happen against a real one —
|
||||
// and if it ever did, an unauthenticated web surface must not be reported as a success.
|
||||
bail!(
|
||||
"the sidecar reported an empty auth token from {}. Authentication is always on; refusing \
|
||||
to continue with a config that would leave its web surface unauthenticated.",
|
||||
doc.config_path
|
||||
);
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Asks an installed binary for its version line, `uo-link-sidecar <ver> (protocol <n>)`.
|
||||
///
|
||||
/// Used to report what is already installed on a run that installs nothing. It is deliberately
|
||||
/// tolerant — a binary that cannot answer is described as unknown rather than failing a run whose
|
||||
/// real work has already succeeded.
|
||||
pub fn version_line(binary: &Path) -> Option<String> {
|
||||
let output = run(&binary.to_string_lossy(), &["--version"]).ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.find(|l| !l.trim().is_empty())
|
||||
.map(|l| l.trim().to_string())
|
||||
}
|
||||
|
||||
/// The two URLs the website needs, composed from the sidecar's own answers plus a host.
|
||||
///
|
||||
/// The bind address is **not** echoed: `[web] bind` is `127.0.0.1` by default and frequently
|
||||
/// `0.0.0.0`, and neither is something to hand to a website (PLAN.md §6). Only the port is taken
|
||||
/// from it; the host is the one the operator named or the installer detected.
|
||||
pub fn website_urls(doc: &ConfigDoc, host: &str) -> (String, String) {
|
||||
let port = port_of(&doc.web.bind);
|
||||
let ws_path = if doc.web.ws_path.starts_with('/') {
|
||||
doc.web.ws_path.clone()
|
||||
} else {
|
||||
format!("/{}", doc.web.ws_path)
|
||||
};
|
||||
(
|
||||
format!("http://{host}:{port}"),
|
||||
format!("ws://{host}:{port}{ws_path}"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The port half of a bind address.
|
||||
///
|
||||
/// Split on the **last** colon so an IPv6 bind (`[::]:8080`) yields `8080` rather than a fragment
|
||||
/// of the address. A bind with no port at all is not something the sidecar produces, so the whole
|
||||
/// string is handed back rather than guessing a default that would then be wrong everywhere it was
|
||||
/// printed.
|
||||
fn port_of(bind: &str) -> &str {
|
||||
match bind.rsplit_once(':') {
|
||||
Some((_, port)) if !port.is_empty() => port,
|
||||
_ => bind,
|
||||
}
|
||||
}
|
||||
|
||||
/// The end-of-run block from PLAN.md §6 — the one manual step the installer cannot do.
|
||||
///
|
||||
/// Returned as a string rather than printed so it can be tested, and so the caller decides where it
|
||||
/// goes. It goes to stdout. It never goes to a file.
|
||||
pub fn handoff(doc: &ConfigDoc, host: &str, site_url: Option<&str>) -> String {
|
||||
let (base_url, ws_url) = website_urls(doc, host);
|
||||
let site = site_url
|
||||
.map(|s| s.trim_end_matches('/').to_string())
|
||||
.unwrap_or_else(|| "https://<your-site>".to_string());
|
||||
|
||||
format!(
|
||||
"\nRunic Gateway is installed.\n\n\
|
||||
One manual step remains — connect the website to this sidecar:\n\n \
|
||||
Base URL {base_url}\n \
|
||||
WebSocket URL {ws_url}\n \
|
||||
Protocol version {protocol}\n \
|
||||
Auth token {token}\n \
|
||||
(also in {config})\n\n\
|
||||
Paste these into Admin → Shard on your Runic Gateway site:\n \
|
||||
{site}/admin/shard\n\n\
|
||||
The token is write-only once saved — the site will never show it back to you.\n",
|
||||
protocol = doc.protocol,
|
||||
token = doc.web.auth_token,
|
||||
config = doc.config_path,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::TempDir;
|
||||
|
||||
/// The document link v1.1.0 actually prints, copied from INSTALL.md Appendix A3.
|
||||
const PRINT_CONFIG: &str = r#"{
|
||||
"component": "uo-link-sidecar",
|
||||
"version": "1.1.0",
|
||||
"protocol": 3,
|
||||
"config_path": "/etc/runicgateway/sidecar.toml",
|
||||
"config_created": true,
|
||||
"token_generated": true,
|
||||
"shard": { "bind": "127.0.0.1:7788" },
|
||||
"web": {
|
||||
"bind": "127.0.0.1:8080",
|
||||
"ws_path": "/ws",
|
||||
"auth_required": true,
|
||||
"auth_token": "4f9c00112233445566778899aabbccdd"
|
||||
},
|
||||
"store": { "path": "/var/lib/runicgateway/uo-link.db" }
|
||||
}"#;
|
||||
|
||||
fn doc() -> ConfigDoc {
|
||||
serde_json::from_str(PRINT_CONFIG).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_documented_print_config_output_parses() {
|
||||
let doc = doc();
|
||||
assert_eq!(doc.version, "1.1.0");
|
||||
assert_eq!(doc.protocol, 3);
|
||||
assert!(doc.web.auth_required);
|
||||
assert_eq!(doc.store.path, "/var/lib/runicgateway/uo-link.db");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_sidecar_adding_fields_still_parses() {
|
||||
// The sidecar and the installer version independently; a key added to the document must not
|
||||
// strand an installed installer.
|
||||
let body = PRINT_CONFIG.replace(
|
||||
"\"protocol\": 3,",
|
||||
"\"protocol\": 3, \"something_new\": { \"nested\": true },",
|
||||
);
|
||||
assert!(serde_json::from_str::<ConfigDoc>(&body).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_website_urls_use_the_host_not_the_bind_address() {
|
||||
// The whole point of asking for a host: 127.0.0.1 and 0.0.0.0 are both useless to a website.
|
||||
let mut d = doc();
|
||||
let (base, ws) = website_urls(&d, "shard.example.com");
|
||||
assert_eq!(base, "http://shard.example.com:8080");
|
||||
assert_eq!(ws, "ws://shard.example.com:8080/ws");
|
||||
|
||||
d.web.bind = "0.0.0.0:9001".into();
|
||||
let (base, ws) = website_urls(&d, "shard.example.com");
|
||||
assert_eq!(base, "http://shard.example.com:9001");
|
||||
assert_eq!(ws, "ws://shard.example.com:9001/ws");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ipv6_bind_yields_its_port() {
|
||||
// Splitting on the first colon would produce "http://host::" from "[::]:8080".
|
||||
assert_eq!(port_of("[::]:8080"), "8080");
|
||||
assert_eq!(port_of("[::1]:7788"), "7788");
|
||||
assert_eq!(port_of("127.0.0.1:8080"), "8080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_handoff_carries_every_value_the_admin_form_asks_for() {
|
||||
// INSTALL.md §5 maps four fields; all four must be in the block, plus where to paste them.
|
||||
let doc = doc();
|
||||
let block = handoff(&doc, "shard.example.com", Some("https://my-site.example/"));
|
||||
assert!(block.contains("http://shard.example.com:8080"), "{block}");
|
||||
assert!(block.contains("ws://shard.example.com:8080/ws"), "{block}");
|
||||
assert!(block.contains("Protocol version 3"), "{block}");
|
||||
assert!(block.contains(&doc.web.auth_token), "{block}");
|
||||
// The trailing slash on the site URL must not produce a double slash in the link.
|
||||
assert!(
|
||||
block.contains("https://my-site.example/admin/shard"),
|
||||
"{block}"
|
||||
);
|
||||
assert!(block.contains("/etc/runicgateway/sidecar.toml"), "{block}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_handoff_still_works_without_a_site_url() {
|
||||
// An unattended run has nobody to ask, and the token is far too useful to withhold over a
|
||||
// link the operator does not need.
|
||||
let block = handoff(&doc(), "shard", None);
|
||||
assert!(block.contains("https://<your-site>/admin/shard"), "{block}");
|
||||
assert!(block.contains("4f9c"), "{block}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_installed_binary_matching_the_bundle_is_left_alone() {
|
||||
let dir = TempDir::new("rg-test-sidecar").unwrap();
|
||||
let dest = dir.path().join("uo-link-sidecar");
|
||||
assert_eq!(
|
||||
decide(&asset("0".repeat(64)), &dest).unwrap(),
|
||||
BinaryAction::Install
|
||||
);
|
||||
|
||||
fs::write(&dest, b"pretend binary").unwrap();
|
||||
let installed = sha256_file(&dest).unwrap();
|
||||
assert_eq!(
|
||||
decide(&asset(installed.clone()), &dest).unwrap(),
|
||||
BinaryAction::Unchanged
|
||||
);
|
||||
// Hex case must not decide whether a host reinstalls its sidecar on every run.
|
||||
assert_eq!(
|
||||
decide(&asset(installed.to_uppercase()), &dest).unwrap(),
|
||||
BinaryAction::Unchanged
|
||||
);
|
||||
assert_eq!(
|
||||
decide(&asset("a".repeat(64)), &dest).unwrap(),
|
||||
BinaryAction::Replace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_staging_file_sits_beside_its_target() {
|
||||
// /tmp and /usr/bin are routinely different filesystems, and rename across them fails.
|
||||
let dest = Path::new("/usr/bin/runicgateway-link");
|
||||
assert_eq!(pending_path(dest).parent(), dest.parent());
|
||||
assert_eq!(
|
||||
pending_path(Path::new("/usr/bin/x.exe"))
|
||||
.file_name()
|
||||
.unwrap(),
|
||||
"x.exe.new"
|
||||
);
|
||||
}
|
||||
|
||||
fn asset(sha256: String) -> Asset {
|
||||
Asset {
|
||||
name: "uo-link-sidecar-linux-x86_64".into(),
|
||||
url: "https://example/uo-link-sidecar".into(),
|
||||
sha256,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user