mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-11-09 20:04:34 +00:00
The feed test wrote its fixture under a predictable PID-based name in the shared temp root. Another local user could pre-create that tree with symlinks, so the fixture writes would follow them with the test runner's permissions. Use tempfile::tempdir, which creates a fresh 0700 directory with an unpredictable name and removes it when dropped.
886 lines
39 KiB
Rust
886 lines
39 KiB
Rust
//! `cargo xtask local`: install the Craft apps from builds on this machine instead of GitHub.
|
|
//!
|
|
//! - `sync` pulls every app's checkout (the sibling folders of this repo), builds and packages the
|
|
//! ones whose version isn't in the mirror yet, and files the results under
|
|
//! `<mirror>/<app>/<version>/` with a `SHA256SUMS.txt`.
|
|
//! - `serve` serves the mirror on 127.0.0.1 and answers `release_info.json` with a feed (the same
|
|
//! shape as ArtCraft's) written fresh from whatever is in the mirror at that moment.
|
|
//!
|
|
//! Run the launcher with `ARTCRAFT_LAUNCHER_DOWNLOAD_BASE=http://127.0.0.1:<port>/` and it installs
|
|
//! and updates from the mirror alone.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::fmt::Write as _;
|
|
use std::io::{Read, Seek, SeekFrom, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
/// Every Craft App: (id = folder name, display name). Keep in step with `catalog::SUITE`.
|
|
const APPS: &[(&str, &str)] = &[
|
|
("photocraft", "PhotoCraft"),
|
|
("vectorcraft", "VectorCraft"),
|
|
("filmcraft", "FilmCraft"),
|
|
("lightcraft", "LightCraft"),
|
|
("pdfcraft", "PdfCraft"),
|
|
("effectcraft", "EffectCraft"),
|
|
("designcraft", "DesignCraft"),
|
|
("figcraft", "FigCraft"),
|
|
("wordcraft", "WordCraft"),
|
|
("soundcraft", "SoundCraft"),
|
|
("deckcraft", "DeckCraft"),
|
|
("cadcraft", "CADCraft"),
|
|
("gridcraft", "GridCraft"),
|
|
("craft-anything", "Craft Anything"),
|
|
("texturecraft", "TextureCraft"),
|
|
("materialcraft", "MaterialCraft"),
|
|
];
|
|
|
|
const DEFAULT_PORT: u16 = 8787;
|
|
/// Marks a version folder whose build finished; anything without it is ignored and rebuilt.
|
|
const COMPLETE: &str = ".complete";
|
|
const SUMS: &str = "SHA256SUMS.txt";
|
|
const NOTES: &str = "NOTES.md";
|
|
/// The commit a version folder was built from.
|
|
const COMMIT: &str = ".commit";
|
|
|
|
pub const USAGE: &str = "\
|
|
usage: cargo xtask local <command> [options]
|
|
|
|
commands:
|
|
sync [options] [-- <package.sh args>]
|
|
git pull every app checkout, then build and package each app whose version
|
|
isn't in the mirror yet
|
|
--root <dir> folder holding the app checkouts (default: this repo's parent)
|
|
--mirror <dir> where builds are kept (default: <root>/local-mirror)
|
|
--only <a,b> just these apps
|
|
--no-pull don't pull; build what's checked out
|
|
--rebuild build even when the version is already mirrored
|
|
--every-commit build each new commit, as <next patch>-dev.<commit time> (0.4.0 builds
|
|
as 0.4.1-dev.20261010183500), so the launcher offers it as an update
|
|
--arch <arch> macOS: universal | aarch64 | x86_64 (default: this Mac's)
|
|
--keep <n> versions kept per app (default 2)
|
|
serve [--mirror <dir>] [--port <n>] [--sync-every <interval> [sync options]]
|
|
serve the mirror and its feed on 127.0.0.1 (default port 8787); with
|
|
--sync-every (30m, 1h, 1d…), also run `sync` now and then on that interval
|
|
status [--root <dir>] [--mirror <dir>]
|
|
each app's checked-out version and commit next to what's mirrored
|
|
|
|
then: ARTCRAFT_LAUNCHER_DOWNLOAD_BASE=http://127.0.0.1:8787/ cargo run -p artcraft-launcher --features local-mirror
|
|
";
|
|
|
|
#[derive(Clone)]
|
|
struct Opts {
|
|
root: PathBuf,
|
|
mirror: PathBuf,
|
|
only: Option<Vec<String>>,
|
|
pull: bool,
|
|
rebuild: bool,
|
|
every_commit: bool,
|
|
arch: Option<String>,
|
|
keep: usize,
|
|
port: u16,
|
|
/// `serve` also syncs this often (seconds).
|
|
sync_every: Option<u64>,
|
|
passthrough: Vec<String>,
|
|
}
|
|
|
|
pub fn run(args: &[&str]) -> Result<(), String> {
|
|
let (cmd, rest) = args.split_first().ok_or_else(|| USAGE.to_string())?;
|
|
let opts = parse(rest)?;
|
|
match *cmd {
|
|
"sync" => sync(&opts),
|
|
"serve" => serve(&opts),
|
|
"status" => status(&opts),
|
|
"-h" | "--help" | "help" => {
|
|
print!("{USAGE}");
|
|
Ok(())
|
|
}
|
|
other => Err(format!("unknown command `local {other}`\n\n{USAGE}")),
|
|
}
|
|
}
|
|
|
|
fn parse(args: &[&str]) -> Result<Opts, String> {
|
|
let default_root = crate::root().parent().map(Path::to_path_buf).ok_or("this repo has no parent folder")?;
|
|
let mut root = None;
|
|
let mut mirror = None;
|
|
let mut o = Opts {
|
|
root: PathBuf::new(),
|
|
mirror: PathBuf::new(),
|
|
only: None,
|
|
pull: true,
|
|
rebuild: false,
|
|
every_commit: false,
|
|
arch: None,
|
|
keep: 2,
|
|
port: DEFAULT_PORT,
|
|
sync_every: None,
|
|
passthrough: Vec::new(),
|
|
};
|
|
let mut it = args.iter();
|
|
while let Some(arg) = it.next() {
|
|
let mut value = || it.next().map(|v| (*v).to_string()).ok_or_else(|| format!("{arg} needs a value"));
|
|
match *arg {
|
|
"--root" => root = Some(PathBuf::from(value()?)),
|
|
"--mirror" => mirror = Some(PathBuf::from(value()?)),
|
|
"--only" => o.only = Some(value()?.split(',').map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()).collect()),
|
|
"--no-pull" => o.pull = false,
|
|
"--rebuild" => o.rebuild = true,
|
|
"--every-commit" => o.every_commit = true,
|
|
"--arch" => o.arch = Some(value()?),
|
|
"--keep" => o.keep = value()?.parse().ok().filter(|n| *n >= 1).ok_or("--keep needs a number of at least 1")?,
|
|
"--port" => o.port = value()?.parse().map_err(|_| "--port needs a port number")?,
|
|
"--sync-every" => o.sync_every = Some(parse_interval(&value()?)?),
|
|
"--" => {
|
|
o.passthrough = it.by_ref().map(|s| (*s).to_string()).collect();
|
|
}
|
|
other => return Err(format!("unknown option {other}\n\n{USAGE}")),
|
|
}
|
|
}
|
|
o.root = root.unwrap_or(default_root);
|
|
o.mirror = mirror.unwrap_or_else(|| o.root.join("local-mirror"));
|
|
if let Some(only) = &o.only
|
|
&& let Some(bad) = only.iter().find(|id| !APPS.iter().any(|(a, _)| a == id))
|
|
{
|
|
return Err(format!("unknown app `{bad}` (known: {})", APPS.iter().map(|a| a.0).collect::<Vec<_>>().join(", ")));
|
|
}
|
|
Ok(o)
|
|
}
|
|
|
|
fn selected(o: &Opts) -> impl Iterator<Item = &'static (&'static str, &'static str)> + '_ {
|
|
APPS.iter().filter(|(id, _)| o.only.as_ref().is_none_or(|only| only.iter().any(|x| x == id)))
|
|
}
|
|
|
|
// ---- sync ---------------------------------------------------------------------------------------
|
|
|
|
enum Outcome {
|
|
Built(String),
|
|
UpToDate(String),
|
|
Skipped(String),
|
|
Failed(String),
|
|
}
|
|
|
|
fn sync(o: &Opts) -> Result<(), String> {
|
|
std::fs::create_dir_all(&o.mirror).map_err(|e| format!("couldn't create {} ({e})", o.mirror.display()))?;
|
|
let mut results = Vec::new();
|
|
for (id, name) in selected(o) {
|
|
eprintln!("\n=== {name} ===");
|
|
let outcome = sync_one(o, id, name).unwrap_or_else(Outcome::Failed);
|
|
results.push((*name, outcome));
|
|
}
|
|
println!("\nlocal sync: {}", o.mirror.display());
|
|
let mut failed = 0;
|
|
for (name, outcome) in &results {
|
|
let (tag, what) = match outcome {
|
|
Outcome::Built(w) => ("built", w),
|
|
Outcome::UpToDate(w) => ("ok", w),
|
|
Outcome::Skipped(w) => ("skip", w),
|
|
Outcome::Failed(w) => {
|
|
failed += 1;
|
|
("FAIL", w)
|
|
}
|
|
};
|
|
println!(" {tag:<6} {name:<12} {what}");
|
|
}
|
|
if failed == 0 { Ok(()) } else { Err(format!("{failed} app(s) failed")) }
|
|
}
|
|
|
|
fn sync_one(o: &Opts, id: &str, name: &str) -> Result<Outcome, String> {
|
|
let repo = o.root.join(id);
|
|
if !repo.join(".git").exists() {
|
|
return Ok(Outcome::Skipped(format!("no checkout at {} (git clone git@github.com:storytold/{id}.git)", repo.display())));
|
|
}
|
|
let mut pull_note = String::new();
|
|
if o.pull {
|
|
// --ff-only never makes a merge commit in someone's checkout; a diverged or dirty one is
|
|
// reported and built as it stands.
|
|
let ok = Command::new("git")
|
|
.arg("-C")
|
|
.arg(&repo)
|
|
.args(["pull", "--ff-only", "--no-stat", "--recurse-submodules"])
|
|
.status()
|
|
.map_err(|e| format!("couldn't run git ({e})"))?
|
|
.success();
|
|
if !ok {
|
|
pull_note = " (pull failed; built the checkout as it is)".into();
|
|
}
|
|
}
|
|
let checkout = checkout_version(&repo)?;
|
|
let commit = git(&repo, &["rev-parse", "--short=12", "HEAD"])?;
|
|
let app_dir = o.mirror.join(id);
|
|
let version = if o.every_commit {
|
|
// The newest mirrored build is already this commit: nothing to do.
|
|
if !o.rebuild
|
|
&& let Some(latest) = latest_version(&app_dir)
|
|
&& std::fs::read_to_string(app_dir.join(&latest).join(COMMIT)).is_ok_and(|c| c.trim() == commit)
|
|
{
|
|
return Ok(Outcome::UpToDate(format!("{latest} is {commit}{pull_note}")));
|
|
}
|
|
let committed = git(&repo, &["show", "-s", "--format=%ct", "HEAD"])?.parse::<i64>().map_err(|_| "unreadable commit time")?;
|
|
dev_version(&checkout, committed)?
|
|
} else {
|
|
checkout
|
|
};
|
|
let dest = app_dir.join(&version);
|
|
if !o.rebuild && dest.join(COMPLETE).is_file() {
|
|
let built_from = std::fs::read_to_string(dest.join(COMMIT)).unwrap_or_default();
|
|
let note = if built_from.trim() == commit {
|
|
String::new()
|
|
} else {
|
|
format!(
|
|
"; {commit} is newer than the mirrored {} but has the same version (--every-commit builds each commit, --rebuild replaces this one)",
|
|
built_from.trim()
|
|
)
|
|
};
|
|
return Ok(Outcome::UpToDate(format!("{version} already mirrored{note}{pull_note}")));
|
|
}
|
|
|
|
// Build into a scratch folder, then swap it in, so a failed build never leaves half a version.
|
|
let building = app_dir.join(format!(".building-{version}"));
|
|
let _ = std::fs::remove_dir_all(&building);
|
|
std::fs::create_dir_all(&building).map_err(|e| format!("couldn't create {} ({e})", building.display()))?;
|
|
let mut cmd = package_command(&repo, o)?;
|
|
cmd.current_dir(&repo).env("DIST", &building);
|
|
if o.every_commit {
|
|
// Every app's packaging reads <APP>_VERSION in place of its Cargo.toml version, and keeps
|
|
// the full version (with the -dev suffix) in the installed copy, where the launcher reads it.
|
|
cmd.env(format!("{}_VERSION", id.to_ascii_uppercase()), &version);
|
|
}
|
|
eprintln!("$ {name}: packaging {version} ({commit})");
|
|
let status = cmd.status().map_err(|e| format!("couldn't run the packaging script ({e})"))?;
|
|
if !status.success() {
|
|
let _ = std::fs::remove_dir_all(&building);
|
|
return Err(format!("packaging failed ({status}){pull_note}"));
|
|
}
|
|
let files = asset_files(&building)?;
|
|
if files.is_empty() {
|
|
let _ = std::fs::remove_dir_all(&building);
|
|
return Err("packaging produced no files".into());
|
|
}
|
|
write_sums(&building, &files)?;
|
|
let previous = latest_version(&app_dir).filter(|v| *v != version);
|
|
let notes = notes(&repo, previous.as_ref().and_then(|v| std::fs::read_to_string(app_dir.join(v).join(COMMIT)).ok()).as_deref(), &commit);
|
|
write(&building.join(NOTES), ¬es)?;
|
|
write(&building.join(COMMIT), &commit)?;
|
|
write(&building.join(COMPLETE), "")?;
|
|
let _ = std::fs::remove_dir_all(&dest);
|
|
std::fs::rename(&building, &dest).map_err(|e| format!("couldn't move the build into place ({e})"))?;
|
|
prune(&app_dir, o.keep);
|
|
Ok(Outcome::Built(format!("{version} ({commit}, {} files){pull_note}", files.len())))
|
|
}
|
|
|
|
/// This OS's packaging script for the checkout at `repo`.
|
|
fn package_command(repo: &Path, o: &Opts) -> Result<Command, String> {
|
|
let mut cmd = if cfg!(target_os = "macos") {
|
|
let mut c = Command::new("bash");
|
|
c.arg(repo.join("packaging/macos/package.sh"));
|
|
let arch = o.arch.clone().unwrap_or_else(|| if cfg!(target_arch = "aarch64") { "aarch64".into() } else { "x86_64".into() });
|
|
c.args(["--arch", &arch]);
|
|
c
|
|
} else if cfg!(target_os = "linux") || cfg!(target_os = "freebsd") {
|
|
let os = if cfg!(target_os = "linux") { "linux" } else { "freebsd" };
|
|
let mut c = Command::new("bash");
|
|
c.arg(repo.join(format!("packaging/{os}/package.sh")));
|
|
c
|
|
} else if cfg!(windows) {
|
|
let mut c = Command::new("powershell");
|
|
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]).arg(repo.join("packaging/windows/package.ps1"));
|
|
c
|
|
} else {
|
|
return Err("no packaging script for this OS".into());
|
|
};
|
|
cmd.args(&o.passthrough);
|
|
Ok(cmd)
|
|
}
|
|
|
|
/// `[workspace.package] version` (or `[package]`) of the checkout.
|
|
fn checkout_version(repo: &Path) -> Result<String, String> {
|
|
let manifest = std::fs::read_to_string(repo.join("Cargo.toml")).map_err(|e| format!("couldn't read Cargo.toml ({e})"))?;
|
|
crate::version::read(&manifest)
|
|
}
|
|
|
|
fn git(repo: &Path, args: &[&str]) -> Result<String, String> {
|
|
let out = Command::new("git").arg("-C").arg(repo).args(args).output().map_err(|e| format!("couldn't run git ({e})"))?;
|
|
if !out.status.success() {
|
|
return Err(format!("git {} failed: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim()));
|
|
}
|
|
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
|
}
|
|
|
|
/// Release notes: the commits since the last mirrored build (or the last 20).
|
|
fn notes(repo: &Path, since: Option<&str>, commit: &str) -> String {
|
|
let range = since.map(str::trim).filter(|s| !s.is_empty()).map(|s| format!("{s}..HEAD"));
|
|
let mut args = vec!["log", "--no-merges", "--format=* %s (%h)"];
|
|
match &range {
|
|
Some(r) => args.push(r),
|
|
None => args.push("-20"),
|
|
}
|
|
let log = git(repo, &args).unwrap_or_default();
|
|
let mut out = format!("Built locally from `{commit}`.\n");
|
|
if !log.is_empty() {
|
|
out.push_str("\n## Changes\n");
|
|
out.push_str(&log);
|
|
out.push('\n');
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Keeps the newest `keep` complete versions of an app.
|
|
fn prune(app_dir: &Path, keep: usize) {
|
|
let mut versions = complete_versions(app_dir);
|
|
versions.sort_by(|a, b| compare_versions(b, a));
|
|
for old in versions.iter().skip(keep) {
|
|
let _ = std::fs::remove_dir_all(app_dir.join(old));
|
|
}
|
|
}
|
|
|
|
fn write(path: &Path, text: &str) -> Result<(), String> {
|
|
std::fs::write(path, text).map_err(|e| format!("couldn't write {} ({e})", path.display()))
|
|
}
|
|
|
|
// ---- status -------------------------------------------------------------------------------------
|
|
|
|
fn status(o: &Opts) -> Result<(), String> {
|
|
println!("{:<12} {:<12} {:<14} {:<12} built from", "app", "checkout", "commit", "mirrored");
|
|
for (id, name) in selected(o) {
|
|
let repo = o.root.join(id);
|
|
let (version, commit) = if repo.join(".git").exists() {
|
|
(checkout_version(&repo).unwrap_or_else(|_| "?".into()), git(&repo, &["rev-parse", "--short=12", "HEAD"]).unwrap_or_else(|_| "?".into()))
|
|
} else {
|
|
("-".into(), "-".into())
|
|
};
|
|
let app_dir = o.mirror.join(id);
|
|
let mirrored = latest_version(&app_dir);
|
|
let built_from = mirrored.as_ref().and_then(|v| std::fs::read_to_string(app_dir.join(v).join(COMMIT)).ok()).unwrap_or_default();
|
|
println!("{name:<12} {version:<12} {commit:<14} {:<12} {}", mirrored.as_deref().unwrap_or("-"), built_from.trim());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---- mirror contents ----------------------------------------------------------------------------
|
|
|
|
fn complete_versions(app_dir: &Path) -> Vec<String> {
|
|
let Ok(entries) = std::fs::read_dir(app_dir) else { return Vec::new() };
|
|
entries
|
|
.flatten()
|
|
.filter(|e| e.path().join(COMPLETE).is_file())
|
|
.filter_map(|e| e.file_name().to_str().map(str::to_string))
|
|
.filter(|v| parse_version(v).is_some())
|
|
.collect()
|
|
}
|
|
|
|
fn latest_version(app_dir: &Path) -> Option<String> {
|
|
complete_versions(app_dir).into_iter().max_by(|a, b| compare_versions(a, b))
|
|
}
|
|
|
|
/// The installable files in a version folder (everything but our own bookkeeping).
|
|
fn asset_files(dir: &Path) -> Result<Vec<String>, String> {
|
|
let entries = std::fs::read_dir(dir).map_err(|e| format!("couldn't read {} ({e})", dir.display()))?;
|
|
let mut names: Vec<String> = entries
|
|
.flatten()
|
|
.filter(|e| e.path().is_file())
|
|
.filter_map(|e| e.file_name().to_str().map(str::to_string))
|
|
.filter(|n| !n.starts_with('.') && n != SUMS && n != NOTES && safe_name(n))
|
|
.collect();
|
|
names.sort();
|
|
Ok(names)
|
|
}
|
|
|
|
fn write_sums(dir: &Path, files: &[String]) -> Result<(), String> {
|
|
let mut sums = String::new();
|
|
for name in files {
|
|
let _ = writeln!(sums, "{} {name}", sha256(&dir.join(name))?);
|
|
}
|
|
write(&dir.join(SUMS), &sums)
|
|
}
|
|
|
|
fn read_sums(dir: &Path) -> BTreeMap<String, String> {
|
|
std::fs::read_to_string(dir.join(SUMS))
|
|
.unwrap_or_default()
|
|
.lines()
|
|
.filter_map(|l| {
|
|
let (hash, name) = l.split_once(char::is_whitespace)?;
|
|
Some((name.trim().trim_start_matches('*').to_string(), hash.to_ascii_lowercase()))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// SHA-256 through the system's own tool (files are hundreds of megabytes).
|
|
fn sha256(path: &Path) -> Result<String, String> {
|
|
let out = if cfg!(windows) {
|
|
Command::new("certutil").arg("-hashfile").arg(path).arg("SHA256").output()
|
|
} else if cfg!(target_os = "macos") {
|
|
Command::new("shasum").args(["-a", "256"]).arg(path).output()
|
|
} else {
|
|
Command::new("sha256sum").arg(path).output()
|
|
}
|
|
.map_err(|e| format!("couldn't hash {} ({e})", path.display()))?;
|
|
let text = String::from_utf8_lossy(&out.stdout);
|
|
// shasum/sha256sum: "<hash> <file>"; certutil: the hash alone on the second line.
|
|
text.split_whitespace()
|
|
.find(|w| w.len() == 64 && w.bytes().all(|b| b.is_ascii_hexdigit()))
|
|
.map(str::to_ascii_lowercase)
|
|
.ok_or_else(|| format!("couldn't hash {}", path.display()))
|
|
}
|
|
|
|
// ---- feed ---------------------------------------------------------------------------------------
|
|
|
|
/// A feed (ArtCraft's `release_info` shape) for the newest complete version of every app.
|
|
fn feed(mirror: &Path, base: &str) -> String {
|
|
let now = rfc3339(unix_now());
|
|
let mut apps = String::new();
|
|
for (id, name) in APPS {
|
|
let app_dir = mirror.join(id);
|
|
let Some(version) = latest_version(&app_dir) else { continue };
|
|
let dir = app_dir.join(&version);
|
|
let Ok(files) = asset_files(&dir) else { continue };
|
|
let sums = read_sums(&dir);
|
|
let mut assets = Vec::new();
|
|
for f in &files {
|
|
let size = std::fs::metadata(dir.join(f)).map(|m| m.len()).unwrap_or(0);
|
|
let sha = sums.get(f).map_or_else(|| "null".into(), |h| json_str(h));
|
|
assets.push(format!(
|
|
r#"{{"name":{},"size_bytes":{size},"download_url":{},"sha256":{sha}}}"#,
|
|
json_str(f),
|
|
json_str(&format!("{base}{id}/{version}/{f}"))
|
|
));
|
|
}
|
|
let built = std::fs::metadata(dir.join(COMPLETE)).and_then(|m| m.modified()).ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok());
|
|
let published = built.map_or_else(|| now.clone(), |d| rfc3339(i64::try_from(d.as_secs()).unwrap_or(0)));
|
|
let notes = std::fs::read_to_string(dir.join(NOTES)).unwrap_or_default();
|
|
if !apps.is_empty() {
|
|
apps.push(',');
|
|
}
|
|
let _ = write!(
|
|
apps,
|
|
r#"{}:{{"name":{},"repo":{},"latest_release":{{"tag":{},"name":{},"published_at":{},"html_url":"","prerelease":false,"notes_markdown":{},"assets":[{}]}},"app_last_fetched":{}}}"#,
|
|
json_str(id),
|
|
json_str(name),
|
|
json_str(&format!("storytold/{id}")),
|
|
json_str(&format!("v{version}")),
|
|
json_str(&format!("{name} v{version} (local build)")),
|
|
json_str(&published),
|
|
json_str(¬es),
|
|
assets.join(","),
|
|
json_str(&now),
|
|
);
|
|
}
|
|
format!(
|
|
r#"{{"success":true,"release_info":{{"apps":{{{apps}}},"last_fetched":{}}},"app_renames":[],"repo_renames":[],"important_notices":{{"announcements":[],"alerts":[]}}}}"#,
|
|
json_str(&now)
|
|
)
|
|
}
|
|
|
|
fn json_str(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len() + 2);
|
|
out.push('"');
|
|
for c in s.chars() {
|
|
match c {
|
|
'"' => out.push_str("\\\""),
|
|
'\\' => out.push_str("\\\\"),
|
|
'\n' => out.push_str("\\n"),
|
|
'\r' => out.push_str("\\r"),
|
|
'\t' => out.push_str("\\t"),
|
|
c if (c as u32) < 0x20 => {
|
|
let _ = write!(out, "\\u{:04x}", c as u32);
|
|
}
|
|
c => out.push(c),
|
|
}
|
|
}
|
|
out.push('"');
|
|
out
|
|
}
|
|
|
|
fn unix_now() -> i64 {
|
|
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(0))
|
|
}
|
|
|
|
/// `2026-10-10T12:00:00Z` (through jiff's civil calendar). jiff spans only years -9999..=9999, far
|
|
/// narrower than the whole `i64` the old hand-rolled Hinnant code converted, so the instant is
|
|
/// moved into range by whole 400-year Gregorian cycles (146_097 days) and the real year restored
|
|
/// afterwards. jiff still does the month/day; the cycle count only shifts the year, keeping every
|
|
/// `i64` exact (e.g. `i64::MAX` → year 292277026596) with no epoch fallback.
|
|
fn rfc3339(secs: i64) -> String {
|
|
let rem = secs.rem_euclid(86_400);
|
|
let epoch = jiff::civil::Date::constant(1970, 1, 1);
|
|
let days = secs.div_euclid(86_400);
|
|
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
|
// `rest` is in `0..146_097` (under one 400-year cycle), so `epoch + rest days` lands in
|
|
// 1970..=2369 (inside jiff's range) and `rest * 86_400` never overflows.
|
|
let date = epoch.checked_add(jiff::SignedDuration::from_secs(rest * 86_400)).unwrap_or(epoch);
|
|
// Restore the real year through `i128` so `cycles * 400` can't overflow at the `i64` extremes.
|
|
let y = i128::from(date.year()) + i128::from(cycles) * 400;
|
|
let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX });
|
|
format!("{y:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", date.month(), date.day(), rem / 3600, rem % 3600 / 60, rem % 60)
|
|
}
|
|
|
|
// ---- versions -----------------------------------------------------------------------------------
|
|
|
|
/// `1.2.3` or `1.2.3-pre.4`.
|
|
fn parse_version(v: &str) -> Option<semver::Version> {
|
|
semver::Version::parse(v).ok()
|
|
}
|
|
|
|
/// The version a build of an unreleased commit gets: after `checkout` and before whatever is
|
|
/// released next, ordered by commit time. `0.4.0` → `0.4.1-dev.20261010183500`;
|
|
/// `1.0.0-rc.1` → `1.0.0-rc.1.dev.20261010183500`.
|
|
fn dev_version(checkout: &str, committed: i64) -> Result<String, String> {
|
|
let v = parse_version(checkout).ok_or_else(|| format!("can't read version `{checkout}`"))?;
|
|
let (major, minor, patch) = (v.major, v.minor, v.patch);
|
|
let stamp: String = rfc3339(committed).chars().filter(char::is_ascii_digit).collect();
|
|
Ok(if v.pre.is_empty() {
|
|
format!("{major}.{minor}.{}-dev.{stamp}", patch.checked_add(1).ok_or("version too large")?)
|
|
} else {
|
|
format!("{major}.{minor}.{patch}-{}.dev.{stamp}", v.pre)
|
|
})
|
|
}
|
|
|
|
/// Semver precedence: numeric pre-release parts compare as numbers (`rc.10` is after `rc.9`).
|
|
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
|
|
match (parse_version(a), parse_version(b)) {
|
|
(Some(x), Some(y)) => x.cmp_precedence(&y),
|
|
_ => a.cmp(b),
|
|
}
|
|
}
|
|
|
|
// ---- serve --------------------------------------------------------------------------------------
|
|
|
|
fn serve(o: &Opts) -> Result<(), String> {
|
|
let listener = TcpListener::bind(("127.0.0.1", o.port)).map_err(|e| format!("couldn't listen on 127.0.0.1:{} ({e})", o.port))?;
|
|
let base = format!("http://127.0.0.1:{}/", o.port);
|
|
println!("serving {} at {base}", o.mirror.display());
|
|
println!("feed: {base}release_info.json");
|
|
println!("launch: ARTCRAFT_LAUNCHER_DOWNLOAD_BASE={base} cargo run -p artcraft-launcher --features local-mirror");
|
|
if let Some(every) = o.sync_every {
|
|
// Syncs run one after another on their own thread, so a long build never holds up a
|
|
// download; a version only joins the feed once its build is complete.
|
|
let opts = o.clone();
|
|
std::thread::spawn(move || {
|
|
loop {
|
|
if let Err(e) = sync(&opts) {
|
|
eprintln!("sync: {e}");
|
|
}
|
|
eprintln!("next sync in {}", describe_interval(every));
|
|
std::thread::sleep(std::time::Duration::from_secs(every));
|
|
}
|
|
});
|
|
}
|
|
// Bounded admission: a local dev tool still shouldn't let idle or flooding clients spawn
|
|
// unbounded threads. Refuse new connections past the cap, and never let a failed thread spawn
|
|
// take down the listener (CWE-770).
|
|
const MAX_HANDLERS: usize = 64;
|
|
let active = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
for stream in listener.incoming().flatten() {
|
|
let (mirror, base) = (o.mirror.clone(), base.clone());
|
|
if active.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= MAX_HANDLERS {
|
|
active.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
|
let mut out = stream;
|
|
let _ = respond(&mut out, "503 Service Unavailable", "text/plain", b"busy", false);
|
|
continue;
|
|
}
|
|
let worker_active = active.clone();
|
|
let spawned = std::thread::Builder::new().spawn(move || {
|
|
if let Err(e) = handle(stream, &mirror, &base) {
|
|
eprintln!(" {e}");
|
|
}
|
|
worker_active.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
|
});
|
|
if spawned.is_err() {
|
|
// Couldn't start a worker: drop this connection but keep serving.
|
|
active.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
|
eprintln!(" couldn't start a connection handler; dropping the connection");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Maximum byte length for a single request line or header line (CWE-400, F-019).
|
|
const MAX_LINE_BYTES: usize = 8 * 1024;
|
|
/// Maximum byte length for all request headers combined (CWE-400, F-019).
|
|
const MAX_HEADER_BYTES: usize = 64 * 1024;
|
|
|
|
/// Sanitizes a string for safe terminal logging, escaping control characters and Unicode line
|
|
/// separators to prevent terminal display manipulation (CWE-117, F-020, F-021).
|
|
///
|
|
/// `is_ascii_control` alone is not enough: C1 controls (U+0080..=U+009F), including the 8-bit CSI
|
|
/// byte U+009B, can start or alter ANSI sequences in terminals, and U+2028/U+2029 act as line
|
|
/// breaks in some log viewers. Those are escaped; ordinary printable Unicode stays as-is.
|
|
fn sanitize_for_terminal(s: &str) -> String {
|
|
use std::fmt::Write;
|
|
let mut out = String::with_capacity(s.len());
|
|
for c in s.chars() {
|
|
if c.is_ascii_control() {
|
|
let _ = write!(out, "\\x{:02x}", c as u32);
|
|
} else if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') {
|
|
let _ = write!(out, "\\u{{{:04x}}}", c as u32);
|
|
} else {
|
|
out.push(c);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Reads a line from `stream` enforcing per-byte deadlines and size bounds (CWE-400, F-019).
|
|
fn read_line_bounded(stream: &mut TcpStream, deadline: std::time::Instant, total_read: &mut usize) -> Result<String, String> {
|
|
let mut line = Vec::new();
|
|
let mut byte = [0u8; 1];
|
|
loop {
|
|
if line.len() >= MAX_LINE_BYTES || *total_read >= MAX_HEADER_BYTES {
|
|
return Err("request header size limit exceeded".into());
|
|
}
|
|
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
|
if remaining.is_zero() {
|
|
return Err("request header deadline exceeded".into());
|
|
}
|
|
stream.set_read_timeout(Some(remaining)).map_err(|e| e.to_string())?;
|
|
match stream.read(&mut byte) {
|
|
Ok(0) => break,
|
|
Ok(_) => {
|
|
*total_read += 1;
|
|
if byte[0] == b'\n' {
|
|
break;
|
|
}
|
|
line.push(byte[0]);
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock || e.kind() == std::io::ErrorKind::TimedOut => {
|
|
return Err("request header deadline exceeded".into());
|
|
}
|
|
Err(e) => return Err(e.to_string()),
|
|
}
|
|
}
|
|
if line.ends_with(b"\r") {
|
|
line.pop();
|
|
}
|
|
String::from_utf8(line).map_err(|_| "request header contains invalid UTF-8".into())
|
|
}
|
|
|
|
fn handle(mut stream: TcpStream, mirror: &Path, base: &str) -> Result<(), String> {
|
|
let io = |e: std::io::Error| e.to_string();
|
|
// A single wall-clock deadline across the request line and every header: an idle or trickling
|
|
// client must not be able to hold a handler thread open indefinitely (CWE-770, CWE-400, F-019).
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
|
let mut total_read = 0;
|
|
let line = read_line_bounded(&mut stream, deadline, &mut total_read)?;
|
|
let mut parts = line.split_whitespace();
|
|
let (method, target) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""));
|
|
let mut range_start: Option<u64> = None;
|
|
loop {
|
|
let h = read_line_bounded(&mut stream, deadline, &mut total_read)?;
|
|
if h.is_empty() {
|
|
break;
|
|
}
|
|
if let Some((k, v)) = h.split_once(':')
|
|
&& k.trim().eq_ignore_ascii_case("range")
|
|
{
|
|
range_start = v.trim().strip_prefix("bytes=").and_then(|r| r.strip_suffix('-')).and_then(|n| n.parse().ok());
|
|
}
|
|
}
|
|
let mut out = stream;
|
|
out.set_write_timeout(Some(std::time::Duration::from_secs(15))).map_err(io)?;
|
|
// Headers are in; a legitimate download can take as long as it needs, so lift the deadline.
|
|
let _ = out.set_read_timeout(None);
|
|
let head = method == "HEAD";
|
|
if method != "GET" && !head {
|
|
return respond(&mut out, "405 Method Not Allowed", "text/plain", b"", head);
|
|
}
|
|
let path = target.split(['?', '#']).next().unwrap_or("").trim_start_matches('/');
|
|
// Sanitize client-controlled method and path to prevent terminal escape injection (CWE-117, F-020, F-021).
|
|
eprintln!("{} /{}", sanitize_for_terminal(method), sanitize_for_terminal(path));
|
|
if path == "release_info.json" {
|
|
return respond(&mut out, "200 OK", "application/json", feed(mirror, base).as_bytes(), head);
|
|
}
|
|
// Only <app>/<version>/<file>, and only plain names: nothing outside the mirror.
|
|
let segments: Vec<&str> = path.split('/').collect();
|
|
if segments.len() != 3 || !segments.iter().all(|s| safe_name(s)) || segments.iter().any(|s| s.starts_with('.')) {
|
|
return respond(&mut out, "404 Not Found", "text/plain", b"not found", head);
|
|
}
|
|
let file_path = mirror.join(segments.join("/"));
|
|
let Ok(mut file) = std::fs::File::open(&file_path) else {
|
|
return respond(&mut out, "404 Not Found", "text/plain", b"not found", head);
|
|
};
|
|
let size = file.metadata().map_err(io)?.len();
|
|
let start = range_start.unwrap_or(0);
|
|
if start > 0 && start >= size {
|
|
let header = format!("HTTP/1.1 416 Range Not Satisfiable\r\nContent-Range: bytes */{size}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
|
return out.write_all(header.as_bytes()).map_err(io);
|
|
}
|
|
let header = if start > 0 {
|
|
format!(
|
|
"HTTP/1.1 206 Partial Content\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nContent-Range: bytes {start}-{}/{size}\r\nAccept-Ranges: bytes\r\nConnection: close\r\n\r\n",
|
|
size - start,
|
|
size.saturating_sub(1)
|
|
)
|
|
} else {
|
|
format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {size}\r\nAccept-Ranges: bytes\r\nConnection: close\r\n\r\n"
|
|
)
|
|
};
|
|
out.write_all(header.as_bytes()).map_err(io)?;
|
|
if head {
|
|
return Ok(());
|
|
}
|
|
file.seek(SeekFrom::Start(start)).map_err(io)?;
|
|
std::io::copy(&mut file.take(size - start), &mut out).map(|_| ()).map_err(io)
|
|
}
|
|
|
|
/// `90s`, `30m`, `1h`, `1d` (or plain seconds); at least a minute.
|
|
fn parse_interval(text: &str) -> Result<u64, String> {
|
|
let text = text.trim();
|
|
let split = text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len());
|
|
let (number, unit) = text.split_at(split);
|
|
let n: u64 = number.parse().map_err(|_| format!("`{text}` isn't an interval (try 30m, 1h or 1d)"))?;
|
|
let unit = match unit {
|
|
"" | "s" => 1,
|
|
"m" => 60,
|
|
"h" => 3600,
|
|
"d" => 86_400,
|
|
_ => return Err(format!("`{text}` isn't an interval (try 30m, 1h or 1d)")),
|
|
};
|
|
n.checked_mul(unit).filter(|s| *s >= 60).ok_or_else(|| format!("--sync-every {text}: use at least a minute"))
|
|
}
|
|
|
|
fn describe_interval(secs: u64) -> String {
|
|
match secs {
|
|
s if s % 86_400 == 0 => format!("{}d", s / 86_400),
|
|
s if s % 3600 == 0 => format!("{}h", s / 3600),
|
|
s if s % 60 == 0 => format!("{}m", s / 60),
|
|
s => format!("{s}s"),
|
|
}
|
|
}
|
|
|
|
fn respond(out: &mut TcpStream, status: &str, kind: &str, body: &[u8], head: bool) -> Result<(), String> {
|
|
let header = format!(
|
|
"HTTP/1.1 {status}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
|
|
body.len()
|
|
);
|
|
out.write_all(header.as_bytes()).map_err(|e| e.to_string())?;
|
|
if !head {
|
|
out.write_all(body).map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn safe_name(s: &str) -> bool {
|
|
!s.is_empty() && s != "." && s != ".." && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn dates_and_versions() {
|
|
assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
|
|
assert_eq!(rfc3339(1_791_500_000), "2026-10-08T22:53:20Z");
|
|
// Year 10000 and the `i64` extremes are past jiff's own calendar range (-9999..=9999), but
|
|
// the 400-year cycle reduction keeps the dates the old Hinnant code produced, not the epoch.
|
|
assert_eq!(rfc3339(253_402_300_800), "10000-01-01T00:00:00Z");
|
|
assert_eq!(rfc3339(i64::MAX), "292277026596-12-04T15:30:07Z");
|
|
assert_eq!(rfc3339(i64::MIN), "-292277022657-01-27T08:29:52Z");
|
|
assert_eq!(compare_versions("0.10.0", "0.9.0"), std::cmp::Ordering::Greater);
|
|
assert_eq!(compare_versions("1.0.0", "1.0.0-rc.1"), std::cmp::Ordering::Greater);
|
|
assert_eq!(compare_versions("1.0.0-rc.10", "1.0.0-rc.9"), std::cmp::Ordering::Greater);
|
|
assert!(parse_version("1.2").is_none() && parse_version(".building-1.2.3").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn writes_a_feed_the_launcher_reads() {
|
|
// A fresh private directory with an unpredictable name: a predictable one in the shared
|
|
// temp root could be pre-created by another user, with symlinks where the fixture writes.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mirror = tmp.path().to_path_buf();
|
|
let dir = mirror.join("photocraft").join("0.6.0");
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
std::fs::write(dir.join("photocraft-0.6.0-macos-aarch64.dmg"), b"dmg").unwrap();
|
|
std::fs::write(dir.join(SUMS), format!("{} photocraft-0.6.0-macos-aarch64.dmg\n", "ab".repeat(32))).unwrap();
|
|
std::fs::write(dir.join(NOTES), "Built \"locally\".\n").unwrap();
|
|
// Unfinished builds are invisible.
|
|
std::fs::create_dir_all(mirror.join("photocraft").join("0.7.0")).unwrap();
|
|
std::fs::write(dir.join(COMPLETE), "").unwrap();
|
|
let json = feed(&mirror, "http://127.0.0.1:8787/");
|
|
assert!(json.contains(r#""tag":"v0.6.0""#), "{json}");
|
|
assert!(json.contains(r#""download_url":"http://127.0.0.1:8787/photocraft/0.6.0/photocraft-0.6.0-macos-aarch64.dmg""#));
|
|
assert!(json.contains(r#""size_bytes":3"#) && json.contains(&"ab".repeat(32)));
|
|
assert!(json.contains(r#"Built \"locally\".\n"#));
|
|
assert!(!json.contains("0.7.0"));
|
|
}
|
|
|
|
#[test]
|
|
fn dev_versions_sit_between_releases_and_follow_commit_time() {
|
|
const T: i64 = 1_791_500_000; // 2026-10-08T22:53:20Z
|
|
assert_eq!(dev_version("0.4.0", T).as_deref(), Ok("0.4.1-dev.20261008225320"));
|
|
assert_eq!(dev_version("1.0.0-rc.1", T).as_deref(), Ok("1.0.0-rc.1.dev.20261008225320"));
|
|
let (dev, later) = (dev_version("0.4.0", T).unwrap(), dev_version("0.4.0", T + 60).unwrap());
|
|
assert_eq!(compare_versions(&dev, "0.4.0"), std::cmp::Ordering::Greater);
|
|
assert_eq!(compare_versions(&later, &dev), std::cmp::Ordering::Greater);
|
|
assert_eq!(compare_versions("0.4.1", &later), std::cmp::Ordering::Greater);
|
|
assert!(dev_version("main", T).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn intervals() {
|
|
assert_eq!(parse_interval("30m"), Ok(1800));
|
|
assert_eq!(parse_interval("1h"), Ok(3600));
|
|
assert_eq!(parse_interval("2d"), Ok(172_800));
|
|
assert_eq!(parse_interval("120"), Ok(120));
|
|
for bad in ["", "h", "10s", "1w", "-1h", "99999999999999999999d"] {
|
|
assert!(parse_interval(bad).is_err(), "{bad}");
|
|
}
|
|
assert_eq!(describe_interval(5400), "90m");
|
|
}
|
|
|
|
#[test]
|
|
fn only_plain_names_are_served() {
|
|
assert!(safe_name("photocraft-0.6.0-macos-universal.dmg"));
|
|
for bad in ["", ".", "..", "a/b", "a\\b", "a%2e"] {
|
|
assert!(!safe_name(bad), "{bad}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn terminal_sanitization_escapes_ansi_and_control_codes() {
|
|
assert_eq!(sanitize_for_terminal("GET"), "GET");
|
|
assert_eq!(sanitize_for_terminal("release_info.json"), "release_info.json");
|
|
// ANSI escape code
|
|
assert_eq!(sanitize_for_terminal("\x1b[2Jevil"), "\\x1b[2Jevil");
|
|
// CR / LF / Tab / Bell / Backspace
|
|
assert_eq!(sanitize_for_terminal("foo\r\nbar\t\x07\x08"), "foo\\x0d\\x0abar\\x09\\x07\\x08");
|
|
// C1 controls, including the 8-bit CSI (U+009B) and NEL (U+0085).
|
|
assert_eq!(sanitize_for_terminal("a\u{9b}2Jb\u{85}c"), "a\\u{009b}2Jb\\u{0085}c");
|
|
// Unicode line/paragraph separators can break a log line in some viewers.
|
|
assert_eq!(sanitize_for_terminal("one\u{2028}two\u{2029}three"), "one\\u{2028}two\\u{2029}three");
|
|
// Printable non-ASCII text is preserved.
|
|
assert_eq!(sanitize_for_terminal("caf\u{e9} \u{1f642}"), "caf\u{e9} \u{1f642}");
|
|
}
|
|
|
|
#[test]
|
|
fn read_line_bounded_enforces_limits() {
|
|
use std::net::{Ipv4Addr, TcpListener, TcpStream};
|
|
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
|
|
let client = std::thread::spawn(move || {
|
|
let mut s = TcpStream::connect(addr).unwrap();
|
|
use std::io::Write;
|
|
s.write_all(b"GET /valid HTTP/1.1\r\n").unwrap();
|
|
// Send line exceeding MAX_LINE_BYTES
|
|
let big_line = vec![b'a'; MAX_LINE_BYTES + 10];
|
|
s.write_all(&big_line).unwrap();
|
|
s.write_all(b"\r\n").unwrap();
|
|
});
|
|
|
|
let (mut s, _) = listener.accept().unwrap();
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
|
let mut total = 0;
|
|
let line1 = read_line_bounded(&mut s, deadline, &mut total);
|
|
assert_eq!(line1.unwrap(), "GET /valid HTTP/1.1");
|
|
|
|
let line2 = read_line_bounded(&mut s, deadline, &mut total);
|
|
assert!(line2.is_err(), "must reject oversized line (CWE-400)");
|
|
|
|
client.join().unwrap();
|
|
}
|
|
}
|