- Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
+ Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
diff --git a/ROADMAP.md b/ROADMAP.md
index e403b02d..9604db66 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -63,7 +63,7 @@ native speakers' reviews of the nine language drafts need people.
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
-| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
+| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
@@ -103,7 +103,7 @@ native speakers' reviews of the nine language drafts need people.
| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
| Hindi | hi | 0% | none |
-| Arabic | ar | 0% | none |
+| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; native review and panel mirroring pending |
| French | fr | 95% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
| Indonesian | id | 99% | draft (needs native review) |
@@ -114,7 +114,7 @@ native speakers' reviews of the nine language drafts need people.
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
-French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Vietnamese drafts (Preferences ▸
+French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Arabic drafts (Preferences ▸
Language; Traditional Chinese has no catalog; CJK UI
text uses an installed CJK font, loaded only when needed; adding a language:
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
@@ -127,11 +127,16 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
2. Real-file corpus and measured fidelity: 15–30 h.
3. Tool-by-tool interaction pass against Figma: 40–60 h.
-4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi, Vietnamese and Arabic ([localization-parity](docs/localization-parity.md)).
+4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi and Vietnamese; Arabic native review and panel mirroring ([localization-parity](docs/localization-parity.md)).
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
## Progress log
+- **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged.
+- **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged.
+- **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged.
+- **2026-10-27** · M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged.
+- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
@@ -347,6 +352,12 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) |
+| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. |
+| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. |
+| 2026-10-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. |
+| 2026-10-27 | minor | M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged. |
+| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
@@ -359,6 +370,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding |
| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged |
| 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged |
+| 2026-10-18 | minor | Colour picker models LCH and OKLCH beside Hex, RGB, HSL, HSB and CSS (desktop and web): lightness, chroma and hue fields (OKLCH lightness in percent, chroma to two places), an edit keeps lightness and hue and lowers chroma until the colour fits sRGB, as CSS gamut mapping does; the CSS field reads `lch()` and `oklch()` too. Values read the stored channels as sRGB, like the other models (a Display P3 file's colours aren't converted first). Conversions checked against the CSS Color 4 and OKLab reference values; checked in the desktop app over the control channel. Numbers unchanged |
| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
| 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged |
| 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged |
diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs
index 0c04b732..740e885b 100644
--- a/apps/figcraft-cli/src/args.rs
+++ b/apps/figcraft-cli/src/args.rs
@@ -91,6 +91,15 @@ impl Args {
Ok(figcraft_doc::PrintSettings { bleed, crop_marks: self.flag("crop-marks"), bleed_marks: self.flag("bleed-marks"), cmyk: self.flag("cmyk") })
}
+ /// `--vd-compat bestEffort|exact|ignore` (Android Vector Drawable exports; default bestEffort).
+ pub fn vd_compat(&self) -> CliResult {
+ match self.value("vd-compat") {
+ None => Ok(figcraft_doc::VdCompat::default()),
+ Some(s) => figcraft_doc::VdCompat::from_key(&s)
+ .ok_or_else(|| CliError::Usage(format!("--vd-compat takes bestEffort, exact or ignore, got `{s}`"))),
+ }
+ }
+
pub fn scale(&self) -> CliResult {
match self.value("scale") {
None => Ok(1.0),
diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs
index f746becd..1ba9d60a 100644
--- a/apps/figcraft-cli/src/files.rs
+++ b/apps/figcraft-cli/src/files.rs
@@ -4,7 +4,7 @@ use std::ffi::OsStr;
use std::path::Path;
use std::process::ExitCode;
-use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
+use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings, VdCompat};
use figcraft_engine::{BatchScope, BatchStep, Session};
use serde_json::{Value, json};
@@ -25,7 +25,7 @@ fn ext(path: &str) -> String {
fn image_format(e: &str) -> Option {
match e {
- "png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" => ExportFormat::from_ext(e),
+ "png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" | "xml" => ExportFormat::from_ext(e),
_ => None,
}
}
@@ -225,6 +225,8 @@ pub struct Target {
pub node: Option,
/// PDF only.
pub print: PrintSettings,
+ /// Android Vector Drawable (XML) only.
+ pub vd_compat: VdCompat,
}
pub fn export(file: &str, out: &str, t: &Target) -> CliResult {
@@ -239,15 +241,20 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult {
let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print);
let fmt = match t.format.as_deref() {
Some(f) => {
- image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig)")))?
+ image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig)")))?
}
None => image_format(&ext(out)).unwrap_or(ExportFormat::Png),
};
let target = node.map(|q| find_node(&doc, page, q)).transpose()?;
let single = image_format(&ext(out)).is_some();
let bytes_of = |ids: &[NodeId]| {
- let setting = ExportSetting { format: fmt, print: *print, ..Default::default() };
- figcraft_engine::cmd::file::export_bytes_with(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))
+ let setting = ExportSetting { format: fmt, print: *print, vd_compat: t.vd_compat, ..Default::default() };
+ let (bytes, warnings) = figcraft_engine::cmd::file::export_file(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))?;
+ // What an Android Vector Drawable changed or left out, like the .fig notes.
+ for w in warnings {
+ eprintln!("note: {w}");
+ }
+ Ok::<_, CliError>(bytes)
};
if single {
let bytes = match target {
diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs
index 8c7881c6..971b3905 100644
--- a/apps/figcraft-cli/src/main.rs
+++ b/apps/figcraft-cli/src/main.rs
@@ -17,8 +17,8 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window
USAGE:
figcraft-cli info [--json]
- figcraft-cli export -o [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME]
- [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]
+ figcraft-cli export -o [--format png|jpg|svg|webp|pdf|xml|fig] [--scale N] [--page NAME] [--node ID|NAME]
+ [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore]
figcraft-cli convert
figcraft-cli render -o [--scale N] [--page NAME]
figcraft-cli run [--in ] [--out ]
@@ -144,12 +144,18 @@ fn run(argv: &[String]) -> CliResult {
confined(&a, || files::info(&file, a.flag("json")))
}
"export" => {
- let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
+ let valued = [&["format", "scale", "page", "node", "o", "out", "bleed", "vd-compat"][..], &ROOT_FLAGS[..]].concat();
let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
let file = a.one_positional("export -o ")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o ".into()))?;
- let target =
- files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? };
+ let target = files::Target {
+ format: a.value("format"),
+ scale: a.scale()?,
+ page: a.value("page"),
+ node: a.value("node"),
+ print: a.print()?,
+ vd_compat: a.vd_compat()?,
+ };
confined(&a, || files::export(&file, &out, &target))
}
"convert" => {
diff --git a/apps/figcraft-cli/tests/it/main.rs b/apps/figcraft-cli/tests/it/main.rs
index ae4b0488..2e628773 100644
--- a/apps/figcraft-cli/tests/it/main.rs
+++ b/apps/figcraft-cli/tests/it/main.rs
@@ -10,3 +10,5 @@ mod fonts;
mod pdf;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod pdf_print;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod vector_drawable;
diff --git a/apps/figcraft-cli/tests/it/vector_drawable.rs b/apps/figcraft-cli/tests/it/vector_drawable.rs
new file mode 100644
index 00000000..b99c32cd
--- /dev/null
+++ b/apps/figcraft-cli/tests/it/vector_drawable.rs
@@ -0,0 +1,41 @@
+//! `figcraft-cli export … -o out.xml` (Android Vector Drawable) end to end.
+
+use std::process::Command;
+
+fn bin() -> Command {
+ Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
+}
+
+#[test]
+fn xml_export_writes_a_drawable_and_prints_its_notes() {
+ let d = std::env::temp_dir().join(format!("figcraft-cli-vd-{}", std::process::id()));
+ std::fs::create_dir_all(&d).unwrap();
+ let script = d.join("s.jsonl");
+ std::fs::write(
+ &script,
+ r##"{"command":"shape.rectangle","params":{"x":0,"y":0,"width":24,"height":24,"name":"Dot","props":{"fill":"#ff0000","effects":[{"type":"dropShadow"}]}}}"##,
+ )
+ .unwrap();
+ let file = d.join("doc.figcraft");
+ let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+
+ let xml = d.join("icon.xml");
+ let out = bin().arg("export").arg(&file).arg("-o").arg(&xml).output().unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ let text = std::fs::read_to_string(&xml).unwrap();
+ assert!(text.contains(" bool {
shown.len() == 11 && matches!(code::parse(shown), Ok(code::Code::Online(_)))
}
+/// The links file keeps hashes only, never the codes or the token they wrap.
+fn only_hashes_on_disk(dir: &std::path::Path, file: &str, wrapped: &str, edit: &str, view: &str) {
+ let links = std::fs::read_to_string(dir.join("files").join(file).join("links.json")).unwrap();
+ for secret in [edit, view, &edit.replace('-', ""), &view.replace('-', "")] {
+ assert!(!links.contains(secret), "{links}");
+ }
+ assert!(!links.contains(wrapped.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
+ assert_eq!(links.matches("\"code\"").count(), 2);
+}
+
#[test]
fn a_file_shared_online_is_joined_by_its_codes() {
let dir = TempDir::new("codes-share");
@@ -90,12 +100,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
let before_stop = ids(dir.path());
assert_eq!(before_stop.len(), 4);
// On disk: hashes only, of the codes as of the links' tokens.
- let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
- for secret in [edit.as_str(), view.as_str(), &edit.replace('-', ""), &view.replace('-', "")] {
- assert!(!links.contains(secret), "{links}");
- }
- assert!(!links.contains(p.edit.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
- assert_eq!(links.matches("\"code\"").count(), 2);
+ only_hashes_on_disk(dir.path(), &p.file, &p.edit, &edit, &view);
// Someone types the edit code, the way people type.
let mut b = app("Bo", &url);
assert_eq!(ex(&mut b, "collab.join", json!({"code": edit.to_lowercase().replace('-', " ")}))["status"], "connecting");
diff --git a/apps/figcraft-server/src/library_tests.rs b/apps/figcraft-server/src/library_tests.rs
index f4703f20..5dd45435 100644
--- a/apps/figcraft-server/src/library_tests.rs
+++ b/apps/figcraft-server/src/library_tests.rs
@@ -41,6 +41,36 @@ fn answer(raw: &mut Raw) -> LibraryReply {
}
}
+/// Junk from an edit link is refused and the library unharmed; a wrong token isn't let in.
+fn junk_is_refused(addr: std::net::SocketAddr, edit: &Invite) {
+ let mut raw = Raw::connect(addr);
+ raw.send(&open(edit));
+ assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
+ raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
+ let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
+ let mut rng = Rng::new(5);
+ for round in 0..12 {
+ let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
+ for _ in 0..1 + rng.below(8) {
+ let i = rng.below(junk.len());
+ junk[i] = rng.next_u64() as u8;
+ }
+ raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
+ assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
+ }
+ let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
+ raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
+ assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
+ raw.send(&ClientMsg::Library(LibraryReq::Usage));
+ let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
+ let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
+ assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
+ assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
+ let mut raw = Raw::connect(addr);
+ raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
+ assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
+}
+
#[test]
fn publish_enable_update_and_usage() {
let dir = TempDir::new("library");
@@ -106,34 +136,8 @@ fn publish_enable_update_and_usage() {
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: vec![1, 2, 3] }));
assert!(matches!(answer(&mut raw), LibraryReply::Failed { reason } if reason.contains("only view")));
- // Junk from an edit link: refused, the library unharmed. A wrong token isn't let in.
let edit = Invite::parse(&p.edit).unwrap();
- let mut raw = Raw::connect(addr);
- raw.send(&open(&edit));
- assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
- raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
- let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
- let mut rng = Rng::new(5);
- for round in 0..12 {
- let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
- for _ in 0..1 + rng.below(8) {
- let i = rng.below(junk.len());
- junk[i] = rng.next_u64() as u8;
- }
- raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
- assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
- }
- let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
- raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
- assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
- raw.send(&ClientMsg::Library(LibraryReq::Usage));
- let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
- let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
- assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
- assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
- let mut raw = Raw::connect(addr);
- raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
- assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
+ junk_is_refused(addr, &edit);
assert_eq!(ex(&mut b, "library.list", json!({}))["libraries"][0]["version"], 2);
// Kept on disk: the library is there after the server stops.
diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs
index 1ea420a5..90fce92b 100644
--- a/apps/figcraft-server/src/store.rs
+++ b/apps/figcraft-server/src/store.rs
@@ -52,6 +52,14 @@ use crate::codes::{self, Sealed};
use crate::directory::{Directory, DirectoryConfig};
use crate::disk::{self, LIBRARY, LINKS, LOG, LOG_HEADER, META, SNAPSHOT, USAGE};
+mod backend;
+mod files;
+mod journal;
+mod library;
+mod links;
+
+pub use library::library_name;
+
pub const LOCK: &str = "LOCK";
pub const KEY: &str = "server.key";
pub const FILES: &str = "files";
@@ -541,847 +549,6 @@ impl Store {
people: open.map_or(0, |o| o.people),
}
}
-
- /// Keep a document (its snapshot) as a new file with an edit link and a view link.
- pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> {
- self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view))
- }
-
- /// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's
- /// caps it gets, and the files and bytes kept by the files published from addresses in it. An
- /// address that doesn't parse counts as itself only, ×1, by its exact text.
- fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> {
- let blocks_of = |text: &str| text.parse::().map(|a| ip_blocks(ip_key(a))).unwrap_or_default();
- let kept: Vec<(&str, Vec<_>, u64)> = self
- .files
- .iter()
- .filter_map(|(id, e)| {
- let from = e.meta.ip.as_deref()?;
- Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id)))
- })
- .collect();
- let sum = |mine: &mut dyn Iterator, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n)));
- let blocks = blocks_of(ip);
- if blocks.is_empty() {
- let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip));
- return vec![(1, files, bytes)];
- }
- blocks
- .into_iter()
- .map(|(block, times)| {
- let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block)));
- (times, files, bytes)
- })
- .collect()
- }
-
- /// Keep a document published without the key from `ip`, within the caps on what one address
- /// and everyone may keep: the file, its links and its owner token.
- pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> {
- let h = &self.cfg.hosting;
- let (open, max_files, max_ip, max_all) = (h.open, h.max_files_per_ip, h.max_bytes_per_ip, h.max_total_bytes);
- if !open {
- return Err(KEY_ONLY.into());
- }
- if !self.abuse.allow(ip, Action::Publish, Instant::now()) {
- return Err("too many files published from your address lately: try again in an hour".into());
- }
- let blocks = self.per_block(&ip.to_string());
- for &(times, files, _) in &blocks {
- if files >= max_files.saturating_mul(times as usize) {
- return Err(format!(
- "your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires"
- ));
- }
- }
- let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
- let fits = |n: u64| {
- for &(times, _, used) in &blocks {
- let cap = max_ip.saturating_mul(u64::from(times));
- if used.saturating_add(n) > cap {
- return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20));
- }
- }
- if used_all.saturating_add(n) > max_all {
- return Err("this server is full: it takes no more files for now (try again later, or another server)".into());
- }
- Ok(())
- };
- let (info, edit, view, owner) = self.keep(name, snapshot, Some(ip), &fits)?;
- Ok((info, edit, view, owner.unwrap_or_default()))
- }
-
- /// Keep a document as a new file: published without the key from `ip` (it gets an owner
- /// token), else by the operator. `fits` checks its size against the caps that apply.
- fn keep(
- &mut self,
- name: &str,
- snapshot: &[Change],
- ip: Option,
- fits: &dyn Fn(u64) -> Result<(), String>,
- ) -> Result<(FileInfo, NewLink, NewLink, Option), String> {
- if self.files.len() >= self.cfg.max_files {
- return Err(format!("the server keeps at most {} files", self.cfg.max_files));
- }
- if let Some(why) = self.low_disk() {
- return Err(why);
- }
- let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?;
- if replica.document().node_count() > self.cfg.core.max_nodes {
- return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes));
- }
- let mut doc = replica.document().clone();
- let name = match clean_name(name) {
- n if n.is_empty() => doc.name.clone(),
- n => n,
- };
- doc.name = name.clone();
- let server = Server::new(doc, "", self.cfg.core);
- let state = server.state().map_err(|e| e.to_string())?;
- let bytes = disk::encode_snapshot(1, &state)?;
- if bytes.len() as u64 > self.cfg.max_file_bytes {
- return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20));
- }
- fits(bytes.len() as u64)?;
- let id = loop {
- let id = token::file_id()?;
- if !self.files.contains_key(&id) && !self.dir(&id).exists() {
- break id;
- }
- };
- let t = now();
- let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?;
- let (view, view_token) = new_link(Access::View, "Published (view)", t)?;
- let owner = ip.map(|_| token::secret()).transpose()?;
- let meta =
- Meta { name, created: t, updated: t, seq: 0, library: None, owner: owner.as_deref().map(hash), ip: ip.map(|i| i.to_string()), opened: 0 };
- let dir = self.dir(&id);
- let written = fs::create_dir_all(&dir)
- .map_err(|e| e.to_string())
- .and_then(|()| {
- disk::sync_dir(Some(&self.root.join(FILES)));
- disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string())
- })
- .and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string()))
- .and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()]))
- // Last: a folder without its description is an upload cut short.
- .and_then(|()| write_json(&dir.join(META), &meta));
- if let Err(e) = written {
- let _ = fs::remove_dir_all(&dir);
- return Err(format!("couldn't keep the file: {e}"));
- }
- let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 };
- self.files.insert(id.clone(), entry);
- let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?;
- match ip {
- Some(ip) => log::info!("event=published file={id} bytes={} ip={ip} by=public", bytes.len()),
- None => log::info!("event=published file={id} bytes={} by=key", bytes.len()),
- }
- Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }, owner))
- }
-
- /// The files kept, with what the operator moderates by.
- pub fn listing(&self, open: &dyn Fn(&str) -> Option) -> Vec {
- self.files
- .iter()
- .map(|(id, e)| FileRow {
- info: self.info(id, e, open(id)),
- opened: e.meta.opened,
- expires: self.expires(&e.meta),
- open: e.meta.owner.is_some(),
- ip: e.meta.ip.clone().unwrap_or_default(),
- reports: u32::try_from(self.reports.iter().filter(|r| r.file == *id).count()).unwrap_or(u32::MAX),
- })
- .collect()
- }
-
- /// Owner token `owner` manages file `file` (one published without the key).
- fn owns(&self, file: &str, owner: &str) -> bool {
- let presented = hash(owner);
- self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h))
- }
-
- /// What became of the files these `(file id, owner token)` pairs own: each one's name, links
- /// and expiry, or `None` when that token manages no file of that id here (which doesn't say
- /// whether the id exists). Asking opens nothing: it doesn't count as using a file.
- pub fn owned(&self, files: &[(String, String)]) -> AdminReply {
- if files.len() > MAX_OWNED {
- return AdminReply::Failed { reason: format!("ask about at most {MAX_OWNED} files at a time") };
- }
- let one = |(file, owner): &(String, String)| {
- let e = self.files.get(file).filter(|_| self.owns(file, owner))?;
- Some(OwnedFile { name: e.meta.name.clone(), links: e.links.iter().map(|l| l.id.clone()).collect(), expires: self.expires(&e.meta) })
- };
- AdminReply::Owned(files.iter().map(one).collect())
- }
-
- /// Rename a file (its listing; the document inside keeps its own name).
- pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> {
- let name = clean_name(name);
- if name.is_empty() {
- return Err("give the file a name".into());
- }
- let path = self.dir(file).join(META);
- let entry = self.entry(file)?;
- let before = std::mem::replace(&mut entry.meta.name, name);
- if let Err(e) = write_json(&path, &entry.meta) {
- entry.meta.name = before;
- return Err(format!("couldn't rename the file: {e}"));
- }
- Ok(())
- }
-
- /// Delete the files published without the key that nobody opened for the idle time (not
- /// those open now); their ids.
- pub fn sweep(&mut self) -> Vec {
- let t = now();
- self.next_sweep = t.saturating_add(SWEEP_EVERY);
- let due: Vec =
- self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect();
- for id in &due {
- match self.remove(id) {
- Ok(()) => log::info!("event=expired file={id}"),
- Err(e) => log::warn!("event=expire_failed file={id} reason={e:?}"),
- }
- }
- due
- }
-
- /// Make a file look unused for `secs` more (tests: expiry without waiting).
- #[cfg(test)]
- pub fn age(&mut self, file: &str, secs: u64) {
- let path = self.dir(file).join(META);
- if let Some(e) = self.files.get_mut(file) {
- let m = &mut e.meta;
- (m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs));
- let _ = write_json(&path, m);
- }
- }
-
- /// Make an open file's log refuse writes (tests: a full or failing disk).
- #[cfg(test)]
- pub fn break_log(&mut self, file: &str) {
- let path = self.dir(file).join(LOG);
- if let Some(log) = self.files.get_mut(file).and_then(|e| e.log.as_mut())
- && let Ok(f) = File::open(path)
- {
- log.file = f;
- }
- }
-
- /// Note that a file was opened now (it is used: it doesn't expire).
- fn touch(&mut self, file: &str) {
- let path = self.dir(file).join(META);
- if let Some(e) = self.files.get_mut(file) {
- e.meta.opened = now();
- if let Err(err) = write_json(&path, &e.meta) {
- log::warn!("file {file}: couldn't update {META}: {err}");
- }
- }
- }
-
- /// What the server says about its hosting (with `file`'s expiry).
- fn host_info(&self, file: Option<&str>) -> HostInfo {
- let h = &self.cfg.hosting;
- let expires = file.and_then(|f| self.files.get(f)).and_then(|e| self.expires(&e.meta));
- HostInfo { open: h.open, operator: h.operator.clone(), terms: h.terms.clone(), idle_secs: h.idle_secs, max_file_mb: h.max_file_mb, expires }
- }
-
- /// Block or unblock an address (an IPv6 address: its /64); the address as kept.
- pub fn block(&mut self, ip: &str, on: bool) -> Result {
- let ip = ip_key(ip.trim().parse::().map_err(|_| format!("`{}` isn't an IP address", ip.trim()))?);
- if on && !self.blocked.contains(&ip) && self.blocked.len() >= MAX_BLOCKED {
- return Err(format!("at most {MAX_BLOCKED} addresses can be blocked"));
- }
- let changed = if on { self.blocked.insert(ip) } else { self.blocked.remove(&ip) };
- if changed {
- let list: Vec = self.blocked.iter().map(IpAddr::to_string).collect();
- write_json(&self.root.join(BLOCKED), &list).map_err(|e| format!("couldn't keep the blocked addresses: {e}"))?;
- log::info!("event={} ip={ip}", if on { "blocked" } else { "unblocked" });
- }
- Ok(ip)
- }
-
- /// The blocked addresses.
- pub fn blocked(&self) -> Vec {
- self.blocked.iter().map(IpAddr::to_string).collect()
- }
-
- /// The reports kept, oldest first.
- pub fn reports(&self) -> &[ReportInfo] {
- &self.reports
- }
-
- /// Keep a report about `file` (from someone who joined it with `link`, at `ip`).
- pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
- let reason = clean_reason(reason);
- if reason.is_empty() {
- return Err("say what is wrong with the file".into());
- }
- let name = self.entry(file)?.meta.name.clone();
- if !self.abuse.allow(ip, Action::Report, Instant::now()) {
- return Err("too many reports from your address lately: try again in an hour".into());
- }
- self.reports.push(ReportInfo {
- at: now(),
- file: file.to_string(),
- name,
- link: link.unwrap_or_default().to_string(),
- ip: ip.to_string(),
- reason,
- });
- if self.reports.len() > MAX_REPORTS {
- let extra = self.reports.len() - MAX_REPORTS;
- self.reports.drain(..extra);
- }
- write_json(&self.root.join(REPORTS), &self.reports).map_err(|e| format!("couldn't keep the report: {e}"))?;
- log::info!("event=report file={file} ip={ip}");
- Ok(())
- }
-
- /// The files kept.
- pub fn list(&self, open: &dyn Fn(&str) -> Option) -> Vec {
- self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect()
- }
-
- /// A file's links.
- pub fn links(&mut self, file: &str) -> Result, String> {
- Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect())
- }
-
- /// A new link to a file.
- pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result {
- let max = self.cfg.max_links;
- let path = self.dir(file).join(LINKS);
- let entry = self.entry(file)?;
- if entry.links.len() >= max {
- return Err(format!("a file has at most {max} links: revoke one first"));
- }
- let (link, token) = new_link(access, &clean_name(label), now())?;
- entry.links.push(link.clone());
- if let Err(e) = write_json(&path, &entry.links) {
- entry.links.pop();
- return Err(format!("couldn't keep the link: {e}"));
- }
- Ok(NewLink { link: link.info(file), token })
- }
-
- /// The link of `entry` whose token `token` is (every link is compared, in constant time, so
- /// timing doesn't tell which one matched).
- fn link_of(entry: &Entry, token: &str) -> Option {
- let presented = hash(token);
- let mut found = None;
- for (i, l) in entry.links.iter().enumerate() {
- if same_secret(&presented, &l.hash) {
- found = Some(i);
- }
- }
- found
- }
-
- /// These join codes no longer open anything: typing one says so.
- fn forget_codes(&mut self, codes: impl IntoIterator) {
- self.gone_codes.extend(codes.into_iter().map(|c| c.hash));
- let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES);
- self.gone_codes.drain(..extra);
- }
-
- /// Where the link with the join code `key` is: its file, its place among the file's links
- /// and its token (every code is compared, so timing doesn't tell which one matched).
- fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> {
- let mut found = None;
- for (id, e) in &self.files {
- for (i, l) in e.links.iter().enumerate() {
- if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) {
- found = Some((id.clone(), i, token));
- }
- }
- }
- found
- }
-
- /// A new join code for the link of `file` whose token is `token` (it takes the place of the
- /// link's earlier code): the code, shown this once.
- pub fn set_code(&mut self, file: &str, token: &str) -> Result {
- let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?;
- // 45 random bits: one already in use is all but impossible, and never handed out twice.
- let (code, key) = loop {
- let code = codes::new_code()?;
- let key = codes::Key::new(&code);
- if self.find_code(&key).is_none() {
- break (code, key);
- }
- };
- let sealed = key.seal(token)?;
- let path = self.dir(file).join(LINKS);
- let entry = self.entry(file)?;
- let link = entry.links.get_mut(at).ok_or("that file has no such link")?;
- let before = link.code.replace(sealed);
- let id = link.id.clone();
- if let Err(e) = write_json(&path, &entry.links) {
- if let Some(l) = entry.links.get_mut(at) {
- l.code = before;
- }
- return Err(format!("couldn't keep the join code: {e}"));
- }
- self.forget_codes(before);
- log::info!("event=code file={file} link={id}");
- Ok(code)
- }
-
- /// The file a join code typed at `ip` opens, and its link's token; wrong codes count against
- /// the address (see `abuse`), and nothing is looked up for an address that is locked out.
- pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> {
- let now = Instant::now();
- if let Some(left) = self.abuse.code_locked(ip, now) {
- return Err(format!(
- "Too many wrong codes from your address: try again in {} minutes, or join with the invite link",
- left.as_secs() / 60 + 1
- ));
- }
- if !self.abuse.allow(ip, Action::Code, now) {
- return Err("Too many codes tried from your address: wait a minute and try again".into());
- }
- if !self.abuse.codes_open(now) {
- return Err(CODES_BUSY.into());
- }
- // Anything that isn't a code's symbols is a wrong code too (and costs no hashing).
- let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c));
- let found = key.as_ref().and_then(|k| self.find_code(k));
- self.abuse.code_tried(ip, found.is_some(), now);
- match found {
- Some((file, _, token)) => {
- log::info!("event=code_used file={file} ip={ip}");
- Ok((file, token))
- }
- None => {
- let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash()));
- Err(if gone { CODE_GONE } else { NO_CODE }.into())
- }
- }
- }
-
- /// Revoke a link.
- pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> {
- let path = self.dir(file).join(LINKS);
- let entry = self.entry(file)?;
- let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?;
- let gone = entry.links.remove(at);
- if let Err(e) = write_json(&path, &entry.links) {
- entry.links.insert(at, gone);
- return Err(format!("couldn't revoke the link: {e}"));
- }
- log::info!("revoked link {link} of file {file}");
- self.forget_codes(gone.code);
- Ok(())
- }
-
- /// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no
- /// longer served either way).
- pub fn remove(&mut self, file: &str) -> Result<(), String> {
- // Closed first: an open log would keep the folder from being deleted on Windows.
- let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?;
- self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone()));
- drop(entry);
- let dir = self.dir(file);
- fs::remove_dir_all(&dir)
- .map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?;
- log::info!("removed file {file}");
- Ok(())
- }
-
- /// A file's team library for `ip`, unless its version is `have`; within what one address and
- /// everyone may download (the size is known before anything is read).
- fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option) -> Result {
- let (dir, max) = (self.dir(file), self.cfg.max_file_bytes);
- let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) };
- if have == Some(lib.version) {
- return Ok(LibraryReply::Unchanged { version: lib.version });
- }
- let path = library_file(&dir, lib.version);
- let size = fs::metadata(&path).map_or(0, |m| m.len());
- let now = Instant::now();
- if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) {
- return Err(BUSY_DOWNLOADS.into());
- }
- Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? })
- }
-
- /// Keep `archive` as a file's team library: it must read as a library (with the limits files
- /// using it read it with), be the file's library if it has one, and be a newer version.
- // shortcut: the archive is checked on the hub thread (other files' sessions wait meanwhile);
- // move it to a worker if large libraries make that noticeable.
- pub fn publish_library(&mut self, file: &str, archive: &[u8]) -> Result {
- let max = self.cfg.max_file_bytes;
- if self.cfg.hosting.open && self.entry(file)?.meta.owner.is_some() {
- return Err(NO_PUBLIC_LIBRARY.into());
- }
- if archive.len() as u64 > max {
- return Err(format!("the library is {} MiB; this server keeps files up to {} MiB", archive.len() >> 20, max >> 20));
- }
- // Everything it unpacks to counts against the same cap, so a small archive can't expand
- // into a large library in memory.
- let lib = match figcraft_format::read_library_within(Cursor::new(archive), max) {
- Ok(Some(lib)) => lib,
- Ok(None) => return Err("that isn't a published library".into()),
- Err(e) => return Err(format!("the library can't be read: {e}")),
- };
- if lib.doc.node_count() > self.cfg.core.max_nodes {
- return Err(format!("the library has more than {} layers", self.cfg.core.max_nodes));
- }
- let m = &lib.manifest;
- let dir = self.dir(file);
- let entry = self.entry(file)?;
- if let Some(old) = &entry.meta.library {
- if old.id != m.id {
- return Err(format!("this file's library on the server is another one (\"{}\")", old.name));
- }
- if m.version <= old.version {
- return Err(format!("version {} is on the server already: publish again to follow it", old.version));
- }
- // A jump would let a client use up the numbers and leave no newer version to publish.
- if m.version > old.version.saturating_add(1) {
- return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1)));
- }
- } else if m.version > MAX_FIRST_LIBRARY_VERSION {
- return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version));
- }
- // Each version has a file of its own, written before the description names it: a failed
- // description write or a crash in between leaves the previous version served whole, never
- // the new bytes under the old number.
- let path = dir.join(library_name(m.version));
- disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
- let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version });
- if let Err(e) = write_json(&dir.join(META), &entry.meta) {
- // Never served (the description doesn't name it); the next publish of it replaces it.
- if let Err(r) = fs::remove_file(&path) {
- log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}");
- }
- entry.meta.library = before;
- return Err(format!("couldn't keep the library: {e}"));
- }
- // The previous version isn't served any more.
- if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) {
- log::warn!("file {file}: the previous library version couldn't be deleted: {e}");
- }
- log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len());
- Ok(m.version)
- }
-
- /// A library's usage reports in memory (read from disk the first time; a damaged list starts
- /// over: it is only statistics). `None` when the file has no library.
- fn usage_of(&mut self, file: &str) -> Result