The link both projects wrote twice
dev-updater serves APKs to a phone and ai-app runs model sessions for one. Above the waterline they share nothing. Underneath they are the same program: bound to wg0 so they are not on the LAN, presenting a certificate from a CA the app pins, answering only requests carrying a token enrolled by scanning a QR off the terminal, keeping state in owner-only files outside the repo. Three modules, each extracted only after diffing the two copies and finding nothing between them but a product name and a type parameter. netif fails closed when the tunnel is down. enroll generates, stores and compares the token, and prints the QR, with the URI scheme as the one per-project part. private owns the file modes, taken from ai-app's version because it had already factored out what dev-updater still has inline in two places. Nothing is removed from either project. This is a proposal with a working core, and the README carries the measured evidence -- the enrollment scanner activity differs by its package line and nothing else, the RON format module is byte-identical, and the two copies have each drifted into holding an improvement the other lacks, which is the cost being paid today.
This commit is contained in:
1 parent
995b29f10d
commit
7651d491ac
9 files changed
+871
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
server/target/
|
||||||
@@ -1,2 +1,102 @@
|
|||||||
# wg-server-app
|
# wg-server-app
|
||||||
|
|
||||||
|
The private link between a phone and a machine you run, extracted from the
|
||||||
|
two projects that had each written it.
|
||||||
|
|
||||||
|
`dev-updater` serves locally-built APKs to a phone. `ai-app` runs model
|
||||||
|
sessions for one. They have nothing in common above the waterline — and
|
||||||
|
underneath they are the same program: a server bound to a WireGuard
|
||||||
|
interface so it is not on the LAN, presenting a certificate from a CA the
|
||||||
|
app pins, answering only requests carrying a bearer token that was enrolled
|
||||||
|
by scanning a QR code off the terminal, and keeping its state in
|
||||||
|
owner-only files outside the repo.
|
||||||
|
|
||||||
|
That link was written twice. This is it written once.
|
||||||
|
|
||||||
|
> **Status: proposal with a working core.** Nothing has been removed from
|
||||||
|
> either project yet. The Rust modules here are real, tested and
|
||||||
|
> lint-clean; the rest of this file is the case for what should follow and
|
||||||
|
> what should not.
|
||||||
|
|
||||||
|
## The evidence
|
||||||
|
|
||||||
|
Measured, not estimated — `difflib` over the two working trees on
|
||||||
|
2026-08-28:
|
||||||
|
|
||||||
|
| file | dev-updater | ai-app | identical |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `EnrollmentScanActivity.kt` | 32 lines | 32 lines | **98%** |
|
||||||
|
| `ServerConfig.kt` | 139 | 122 | **89%** |
|
||||||
|
| `auth.rs` | 250 | 230 | **83%** |
|
||||||
|
| `PinnedCert.kt` | 60 | 59 | **75%** |
|
||||||
|
| `certs.rs` | 218 | 199 | **70%** |
|
||||||
|
| RON `format` module | — | — | **byte-identical** |
|
||||||
|
|
||||||
|
The percentages understate it, because the differences are almost entirely
|
||||||
|
a product name and a type parameter:
|
||||||
|
|
||||||
|
- **`EnrollmentScanActivity.kt`** differs by its `package` line. That is all.
|
||||||
|
- **`PinnedCert.kt`** differs by `package` and one string, `"dev-updater-dev-ca"` against `"ai-app-dev-ca"`.
|
||||||
|
- **`certs.rs`** differs by the organisation name in the certificate, and by ai-app having factored the file-mode handling into a `private` module that dev-updater still has inline in two places.
|
||||||
|
- **`auth.rs`** differs by the state type the middleware is generic over (`AppState` against `SessionManager`), and by dev-updater keeping `print_enrollment` in `auth.rs` where ai-app keeps the same function in `main.rs`.
|
||||||
|
- **`wg_address` and `local_addresses`** are the same logic in both, in files that are otherwise 8% alike.
|
||||||
|
|
||||||
|
ai-app's own `Cargo.toml` already says the quiet part: RON is used there
|
||||||
|
because it is *"the same choice, and the same house rules, as the sibling
|
||||||
|
dev-updater project's config."*
|
||||||
|
|
||||||
|
## Why this is worth doing, in one observation
|
||||||
|
|
||||||
|
**The two copies have each drifted into holding an improvement the other
|
||||||
|
lacks.** Not hypothetically — as of the day this was written:
|
||||||
|
|
||||||
|
| improvement | in dev-updater | in ai-app |
|
||||||
|
|---|---|---|
|
||||||
|
| `private::` module owning every file mode | no, inline twice | yes |
|
||||||
|
| `SharedPreferences.edit { }` instead of the deprecated builder | no | yes |
|
||||||
|
| `existingTokenKey()` — reads the key without creating one, so a stored blob with no key means "not enrolled" rather than leaving a stray key behind | yes | no |
|
||||||
|
| `hasLocalNetworkPermission` — tells a denied permission apart from an unreachable server, which are identical at the socket | yes | no |
|
||||||
|
| Catppuccin theme with colour-by-consequence buttons | yes | no, ad-hoc `Color(0xFF…)` per screen |
|
||||||
|
|
||||||
|
Every one of those is a fix somebody made once, in one repo, that the other
|
||||||
|
will either never get or get by being written a third time. That is the
|
||||||
|
cost this repo removes, and it is already being paid.
|
||||||
|
|
||||||
|
## What is here
|
||||||
|
|
||||||
|
`server/` — the `wg-link` crate. Three modules, each extracted only after
|
||||||
|
diffing the two copies and finding nothing but a name between them.
|
||||||
|
|
||||||
|
- **`netif`** — `wg_address()`, which fails closed when the tunnel is down, and `local_addresses()` for the certificate's SANs. The product name is a parameter so the failure reads as advice rather than as a library complaining.
|
||||||
|
- **`enroll`** — token generation, hex-SHA-256 storage, constant-time comparison, the `<scheme>://enroll?…` URI, and the terminal QR. The URI scheme is the parameter, because it is what routes a scan back to the right app.
|
||||||
|
- **`private`** — owner-only files and directories, taken from ai-app's version, with an `append_file` alongside `create_file` because a transcript must never be truncated by being opened.
|
||||||
|
|
||||||
|
## What should follow, and what should not
|
||||||
|
|
||||||
|
**Should follow, in this order.** Each is already near-identical:
|
||||||
|
|
||||||
|
1. `certs` — CA generated once and never replaced, leaf reissued every start. One `product: &str`.
|
||||||
|
2. The RON `format` house rules — byte-identical today, so this is pure deletion.
|
||||||
|
3. The Kotlin `EnrollmentScanActivity`, `PinnedCert`, and the enrollment/Keystore half of `ServerConfig`, as an Android library module. This is where the sharing pays most, because it is where the two copies have drifted furthest apart in *both* directions.
|
||||||
|
4. Atomic owner-only config save. Both do temp-file-then-rename with the mode set before the rename; only the schema differs.
|
||||||
|
|
||||||
|
**Should not.** Naming these is the point of the exercise:
|
||||||
|
|
||||||
|
- **The auth middleware itself.** It is generic over each project's state type. Share the primitives (`enroll`), let each keep the six lines that wire them to its own state — the alternative is a trait that exists only to let one function be shared.
|
||||||
|
- **The HTTP clients.** dev-updater's `DownloadServer.kt` and ai-app's `Api.kt` are 14% alike and 135 against 494 lines. They have diverged because they are genuinely different programs. A shared *pinned transport* underneath them may be worth it later; a shared API is not.
|
||||||
|
- **Config schemas.** Shared house rules, separate contents.
|
||||||
|
- **Anything above the link.** Projects, builds, sessions, providers. If a third project would not want it, it does not belong here.
|
||||||
|
|
||||||
|
## The name
|
||||||
|
|
||||||
|
`wg-server-app` describes the two things it was extracted from. `wg-link`
|
||||||
|
— the crate's name here — describes what it actually is: neither a server
|
||||||
|
nor an app, but the link between them. Renaming the repo to match is a
|
||||||
|
`mv` and a rename on gitea; the crate can also be renamed the other way.
|
||||||
|
Whichever is preferred, it should be one name rather than two.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`./run-tests.sh`. The crate is warning-clean under `cargo clippy
|
||||||
|
--all-targets` and formatted with plain `cargo fmt` at its defaults, per
|
||||||
|
the house rules.
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Runs this crate's tests. Extra arguments are forwarded to `cargo test`.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/server"
|
||||||
|
exec cargo test "$@"
|
||||||
Generated
+327
@@ -0,0 +1,327 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.104"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.23.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"const-oid",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "if-addrs"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.189"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||||
|
dependencies = [
|
||||||
|
"chacha20",
|
||||||
|
"getrandom",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.119"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing"
|
||||||
|
version = "0.1.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
|
dependencies = [
|
||||||
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-attributes"
|
||||||
|
version = "0.1.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-core"
|
||||||
|
version = "0.1.36"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wg-link"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"base64",
|
||||||
|
"if-addrs",
|
||||||
|
"qrcode",
|
||||||
|
"rand",
|
||||||
|
"sha2",
|
||||||
|
"subtle",
|
||||||
|
"tempfile",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "wg-link"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "The private link between a phone and a machine you run: WireGuard binding, a self-signed CA the app pins, and QR enrollment of a bearer token."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
# Enumerating this machine's addresses, and finding the tunnel's.
|
||||||
|
if-addrs = "0.15"
|
||||||
|
# Token auth: hash for storage, constant-time compare for verification,
|
||||||
|
# CSPRNG-backed generation, base64url for the enrollment string.
|
||||||
|
sha2 = "0.11"
|
||||||
|
subtle = "2"
|
||||||
|
rand = "0.10"
|
||||||
|
base64 = "0.23"
|
||||||
|
# Renders the enrollment QR straight to the terminal; no image output.
|
||||||
|
qrcode = { version = "0.14", default-features = false }
|
||||||
|
tracing = "0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
//! The bearer token a phone carries, and the QR code that gets it there.
|
||||||
|
//!
|
||||||
|
//! Pinning authenticates the server to the phone but never the phone to
|
||||||
|
//! the server, so the token supplies the other direction. Binding the
|
||||||
|
//! WireGuard interface (see [`crate::netif`]) narrows who can try at all;
|
||||||
|
//! this narrows it to who was enrolled.
|
||||||
|
//!
|
||||||
|
//! The token is 256 bits from the OS CSPRNG and is never typed by a
|
||||||
|
//! human -- it travels once, in a QR code printed to the terminal -- so
|
||||||
|
//! being unguessable costs nothing and there is no manual-entry path to
|
||||||
|
//! design around.
|
||||||
|
//!
|
||||||
|
//! Only the hash is ever stored. That is what makes the plaintext a
|
||||||
|
//! once-only artifact: it exists in the QR at generation time and nowhere
|
||||||
|
//! afterwards, and a lost phone is answered by rotating rather than by
|
||||||
|
//! looking the old one up.
|
||||||
|
//!
|
||||||
|
//! # Never log the token
|
||||||
|
//!
|
||||||
|
//! Nothing here, and nothing that calls it, may log the Authorization
|
||||||
|
//! header or the token itself. Both existing projects hold a test that
|
||||||
|
//! drives the rejection path under a capturing subscriber and asserts the
|
||||||
|
//! token does not appear in the output; that tripwire belongs with the
|
||||||
|
//! middleware, which stays in each project because it is generic over
|
||||||
|
//! that project's state.
|
||||||
|
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use base64::Engine;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
|
/// 256 bits from the OS CSPRNG, base64url.
|
||||||
|
pub fn generate_token() -> String {
|
||||||
|
use rand::Rng;
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
rand::rng().fill_bytes(&mut bytes);
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the config stores instead of the token: hex SHA-256.
|
||||||
|
///
|
||||||
|
/// A plain hash, not a password KDF, and deliberately: the input is 256
|
||||||
|
/// random bits, so there is nothing to dictionary-attack and stretching
|
||||||
|
/// would buy only latency on every request.
|
||||||
|
pub fn token_hash_hex(token: &str) -> String {
|
||||||
|
Sha256::digest(token.as_bytes())
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `presented` matches any enrolled hash.
|
||||||
|
///
|
||||||
|
/// The fold visits every entry regardless of an earlier match, so the
|
||||||
|
/// time taken does not say which entry matched, or whether the first one
|
||||||
|
/// did.
|
||||||
|
pub fn token_matches(presented: &str, stored_hashes: &[String]) -> bool {
|
||||||
|
let presented = token_hash_hex(presented);
|
||||||
|
stored_hashes.iter().fold(false, |matched, stored| {
|
||||||
|
matched | bool::from(presented.as_bytes().ct_eq(stored.as_bytes()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `<scheme>://enroll?...` URI a QR code carries.
|
||||||
|
///
|
||||||
|
/// The scheme is the caller's because it is what routes the scan back to
|
||||||
|
/// the right app -- `devupdater`, `aiapp` -- and it is the only part of
|
||||||
|
/// enrollment that is per-project.
|
||||||
|
pub fn enrollment_uri(scheme: &str, host: IpAddr, port: u16, token: &str) -> String {
|
||||||
|
format!("{scheme}://enroll?host={host}&port={port}&token={token}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prints the one-time enrollment QR, and the URI under it for a person
|
||||||
|
/// who would rather paste than scan.
|
||||||
|
///
|
||||||
|
/// Printed to stdout rather than through `tracing`: it is for the human
|
||||||
|
/// at the terminal, once, and a log line is the wrong shape for something
|
||||||
|
/// that has to be photographed.
|
||||||
|
///
|
||||||
|
/// The QR carries no trust material. The CA is embedded in the app at
|
||||||
|
/// build time, so photographing the terminal leaks only the token, which
|
||||||
|
/// is rotatable.
|
||||||
|
pub fn print_enrollment(scheme: &str, host: IpAddr, port: u16, token: &str) -> Result<()> {
|
||||||
|
let uri = enrollment_uri(scheme, host, port, token);
|
||||||
|
let code = qrcode::QrCode::new(uri.as_bytes()).context("render enrollment QR")?;
|
||||||
|
let rendered = code
|
||||||
|
.render::<qrcode::render::unicode::Dense1x2>()
|
||||||
|
.quiet_zone(true)
|
||||||
|
.build();
|
||||||
|
println!("\n{rendered}\n");
|
||||||
|
println!("Scan with the phone's camera to enroll (or paste into the app's settings):");
|
||||||
|
println!(" {uri}");
|
||||||
|
println!("The token is not stored in the clear and won't be shown again;");
|
||||||
|
println!("a lost phone means re-running with --rotate-token.\n");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashing_is_stable_and_tokens_verify() {
|
||||||
|
let token = generate_token();
|
||||||
|
assert_eq!(token_hash_hex(&token), token_hash_hex(&token));
|
||||||
|
assert_ne!(token, generate_token(), "tokens must not repeat");
|
||||||
|
|
||||||
|
let hashes = vec![token_hash_hex(&token), token_hash_hex("other")];
|
||||||
|
assert!(token_matches(&token, &hashes));
|
||||||
|
assert!(token_matches("other", &hashes));
|
||||||
|
assert!(!token_matches("wrong", &hashes));
|
||||||
|
assert!(
|
||||||
|
!token_matches(&token, &[]),
|
||||||
|
"no enrolled token matches nothing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hash is what gets stored, so it must not be the token, and it
|
||||||
|
/// must be the shape the config files already hold.
|
||||||
|
#[test]
|
||||||
|
fn the_stored_form_reveals_nothing_and_is_hex() {
|
||||||
|
let token = generate_token();
|
||||||
|
let hash = token_hash_hex(&token);
|
||||||
|
assert_ne!(hash, token);
|
||||||
|
assert_eq!(hash.len(), 64);
|
||||||
|
assert!(
|
||||||
|
hash.chars()
|
||||||
|
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scheme is the only per-project part, and the app parses this
|
||||||
|
/// back -- so the shape is a contract, not a formatting choice.
|
||||||
|
#[test]
|
||||||
|
fn the_enrollment_uri_carries_scheme_host_port_and_token() {
|
||||||
|
let uri = enrollment_uri("devupdater", "10.66.0.1".parse().unwrap(), 8090, "tok");
|
||||||
|
assert_eq!(
|
||||||
|
uri,
|
||||||
|
"devupdater://enroll?host=10.66.0.1&port=8090&token=tok"
|
||||||
|
);
|
||||||
|
let other = enrollment_uri("aiapp", "10.66.0.1".parse().unwrap(), 8443, "tok");
|
||||||
|
assert!(other.starts_with("aiapp://enroll?"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//! What dev-updater and ai-app both need in order to be reached from a
|
||||||
|
//! phone, and nothing either of them does afterwards.
|
||||||
|
//!
|
||||||
|
//! Both projects are the same shape underneath: a server on a machine
|
||||||
|
//! somebody owns, bound to a WireGuard interface so it is not on the LAN,
|
||||||
|
//! presenting a certificate from a CA the app pins, and answering only
|
||||||
|
//! requests carrying a bearer token that was enrolled by scanning a QR
|
||||||
|
//! code off the terminal. None of that is about serving APKs or running
|
||||||
|
//! model sessions -- it is the link, and it was written twice.
|
||||||
|
//!
|
||||||
|
//! # What belongs here
|
||||||
|
//!
|
||||||
|
//! Anything that would be *identical* in a third such project. The test
|
||||||
|
//! applied to each module below was to diff the two existing copies: if
|
||||||
|
//! the only differences were a product name and which state type the code
|
||||||
|
//! was generic over, it came here.
|
||||||
|
//!
|
||||||
|
//! # What deliberately does not
|
||||||
|
//!
|
||||||
|
//! The API surfaces. dev-updater's routes are about projects and builds,
|
||||||
|
//! ai-app's about sessions and providers, and their HTTP clients have
|
||||||
|
//! diverged to 14% similarity because they are genuinely different
|
||||||
|
//! programs. Sharing a transport is worth doing; sharing an API would mean
|
||||||
|
//! inventing a common vocabulary neither project wants.
|
||||||
|
//!
|
||||||
|
//! Config *schemas*, for the same reason -- though the RON house rules
|
||||||
|
//! that both files are written in are shared, since those were identical
|
||||||
|
//! to the byte.
|
||||||
|
|
||||||
|
pub mod enroll;
|
||||||
|
pub mod netif;
|
||||||
|
pub mod private;
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
//! Which address to bind, and which addresses the certificate must cover.
|
||||||
|
//!
|
||||||
|
//! Both projects bind the WireGuard interface and nothing else, so that
|
||||||
|
//! neither is reachable from the LAN. That is the outer of two gates --
|
||||||
|
//! the tunnel decides who can try, the token (see [`crate::enroll`])
|
||||||
|
//! decides who is answered -- and it is worth having on its own account:
|
||||||
|
//! an unenrolled scanner never reaches the token check, and a plain-HTTP
|
||||||
|
//! bootstrap port travels inside the tunnel's encryption.
|
||||||
|
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
/// The interface both projects bind. A constant rather than a parameter
|
||||||
|
/// because a second answer would mean two ideas of what "the tunnel" is.
|
||||||
|
pub const WG_INTERFACE: &str = "wg0";
|
||||||
|
|
||||||
|
/// The alias an Android emulator reaches its host by. Not a real
|
||||||
|
/// interface anywhere, which is why it has to be added by hand.
|
||||||
|
const EMULATOR_HOST_ALIAS: [u8; 4] = [10, 0, 2, 2];
|
||||||
|
|
||||||
|
/// Every address this machine answers on, for the leaf certificate's SANs
|
||||||
|
/// -- so it covers whatever the phone actually dials without anyone
|
||||||
|
/// maintaining a hardcoded IP.
|
||||||
|
///
|
||||||
|
/// Loopback is included for curl and tests, and the emulator's host alias
|
||||||
|
/// so a debug build can reach a server running beside it.
|
||||||
|
///
|
||||||
|
/// Failing to enumerate is not fatal: the certificate still covers
|
||||||
|
/// loopback, which is enough to start and to diagnose from the machine
|
||||||
|
/// itself.
|
||||||
|
pub fn local_addresses() -> Vec<IpAddr> {
|
||||||
|
let mut addresses = vec![
|
||||||
|
IpAddr::from([127, 0, 0, 1]),
|
||||||
|
IpAddr::from(EMULATOR_HOST_ALIAS),
|
||||||
|
];
|
||||||
|
match if_addrs::get_if_addrs() {
|
||||||
|
Ok(interfaces) => {
|
||||||
|
for interface in interfaces {
|
||||||
|
let ip = interface.ip();
|
||||||
|
if ip.is_ipv4() && !addresses.contains(&ip) {
|
||||||
|
addresses.push(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => tracing::warn!("couldn't enumerate interfaces for the certificate: {err}"),
|
||||||
|
}
|
||||||
|
addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The IPv4 address on the WireGuard interface, or a refusal to start.
|
||||||
|
///
|
||||||
|
/// Failing closed rather than falling back to 0.0.0.0 is the point. The
|
||||||
|
/// escape hatch belongs to the caller as an explicit `--bind`, because
|
||||||
|
/// each of these servers is also how something stranded gets recovered,
|
||||||
|
/// and that recovery should not depend on the tunnel being healthy.
|
||||||
|
///
|
||||||
|
/// `product` names the binary in the failure, so the message reads as
|
||||||
|
/// advice rather than as a library complaining.
|
||||||
|
pub fn wg_address(product: &str) -> Result<IpAddr> {
|
||||||
|
let interfaces = if_addrs::get_if_addrs().context("enumerate network interfaces")?;
|
||||||
|
interfaces
|
||||||
|
.into_iter()
|
||||||
|
.find(|iface| iface.name == WG_INTERFACE && iface.ip().is_ipv4())
|
||||||
|
.map(|iface| iface.ip())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"no IPv4 address on interface {WG_INTERFACE} -- {product} binds only to the \
|
||||||
|
WireGuard tunnel, so that only enrolled peers can reach its API. Bring the \
|
||||||
|
tunnel up, or pass --bind 0.0.0.0 to serve the LAN while recovering."
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Whatever this machine has, the two that are not interfaces must be
|
||||||
|
/// there -- loopback for tests and curl, the alias for an emulator --
|
||||||
|
/// and nothing may appear twice, since these become certificate SANs.
|
||||||
|
#[test]
|
||||||
|
fn the_certificate_always_covers_loopback_and_the_emulator_alias() {
|
||||||
|
let addresses = local_addresses();
|
||||||
|
assert!(addresses.contains(&IpAddr::from([127, 0, 0, 1])));
|
||||||
|
assert!(addresses.contains(&IpAddr::from(EMULATOR_HOST_ALIAS)));
|
||||||
|
|
||||||
|
let mut seen = addresses.clone();
|
||||||
|
seen.sort();
|
||||||
|
seen.dedup();
|
||||||
|
assert_eq!(seen.len(), addresses.len(), "duplicate SANs: {addresses:?}");
|
||||||
|
assert!(addresses.iter().all(|ip| ip.is_ipv4()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The failure is the thing a person reads at 2am, so it has to name
|
||||||
|
/// the binary, the interface, and the way out.
|
||||||
|
#[test]
|
||||||
|
fn a_missing_tunnel_explains_itself() {
|
||||||
|
// Only meaningful where there is no wg0; where there is one, the
|
||||||
|
// call succeeds and there is no message to check.
|
||||||
|
if wg_address("demo-server").is_ok() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let err = wg_address("demo-server")
|
||||||
|
.expect_err("no tunnel")
|
||||||
|
.to_string();
|
||||||
|
assert!(err.contains("demo-server"), "{err}");
|
||||||
|
assert!(err.contains(WG_INTERFACE), "{err}");
|
||||||
|
assert!(err.contains("--bind"), "{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
//! Creating files and directories this server alone can read.
|
||||||
|
//!
|
||||||
|
//! Everything a server writes outside its repo goes through here: the
|
||||||
|
//! config holding token hashes, the TLS private keys, and whatever state
|
||||||
|
//! it keeps. One module owns the modes, so "owner-only" is a property
|
||||||
|
//! that can be checked in one place rather than re-argued at every
|
||||||
|
//! `create`.
|
||||||
|
//!
|
||||||
|
//! Taken from ai-app, which had factored this out; dev-updater still has
|
||||||
|
//! the same logic inline in two places, which is the duplication this
|
||||||
|
//! crate exists to end.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
/// Creates `dir` and its parents, owner-accessible only.
|
||||||
|
///
|
||||||
|
/// The mode is set again after creation, deliberately: `DirBuilder::mode`
|
||||||
|
/// applies only when the directory is actually created, so one that
|
||||||
|
/// already existed -- made by hand, or by an older version -- would
|
||||||
|
/// otherwise keep whatever permissions it had while holding a private key.
|
||||||
|
pub fn create_dir(dir: &Path) -> Result<()> {
|
||||||
|
std::fs::DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.create(dir)
|
||||||
|
.with_context(|| format!("create {}", dir.display()))?;
|
||||||
|
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
|
||||||
|
.with_context(|| format!("restrict {}", dir.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `contents` to `path`, owner-readable only.
|
||||||
|
///
|
||||||
|
/// The mode is set as the file is opened rather than chmod-ed afterwards,
|
||||||
|
/// so it is never briefly world-readable at its real path.
|
||||||
|
pub fn write_file(path: &Path, contents: &[u8]) -> Result<()> {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut file = create_file(path)?;
|
||||||
|
file.write_all(contents)
|
||||||
|
.with_context(|| format!("write {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens `path` for writing, owner-readable only, truncating what is
|
||||||
|
/// there. For a caller that streams rather than holding the whole body.
|
||||||
|
pub fn create_file(path: &Path) -> Result<File> {
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(path)
|
||||||
|
.with_context(|| format!("write {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens `path` for appending, owner-readable only, creating it if needed.
|
||||||
|
///
|
||||||
|
/// The append case is separate because a transcript must never be
|
||||||
|
/// truncated by being opened, and the two differ by one flag that is easy
|
||||||
|
/// to get wrong in a hurry.
|
||||||
|
pub fn append_file(path: &Path) -> Result<File> {
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.create(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(path)
|
||||||
|
.with_context(|| format!("append to {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn mode_of(path: &Path) -> u32 {
|
||||||
|
std::fs::metadata(path).expect("stat").permissions().mode() & 0o777
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_directory_is_owner_only_even_if_it_already_existed() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let target = dir.path().join("state");
|
||||||
|
|
||||||
|
// Made by hand, wide open -- what an older version or a person
|
||||||
|
// might leave behind.
|
||||||
|
std::fs::create_dir(&target).expect("mkdir");
|
||||||
|
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||||
|
|
||||||
|
create_dir(&target).expect("create_dir");
|
||||||
|
assert_eq!(
|
||||||
|
mode_of(&target),
|
||||||
|
0o700,
|
||||||
|
"an existing directory must be restricted too"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn files_are_owner_only_from_the_moment_they_exist() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
create_dir(dir.path()).expect("create_dir");
|
||||||
|
|
||||||
|
let written = dir.path().join("key.pem");
|
||||||
|
write_file(&written, b"secret").expect("write");
|
||||||
|
assert_eq!(mode_of(&written), 0o600);
|
||||||
|
assert_eq!(std::fs::read(&written).expect("read"), b"secret");
|
||||||
|
|
||||||
|
let appended = dir.path().join("transcript.jsonl");
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
let mut file = append_file(&appended).expect("append");
|
||||||
|
file.write_all(b"one\n").expect("write");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
let mut file = append_file(&appended).expect("append");
|
||||||
|
file.write_all(b"two\n").expect("write");
|
||||||
|
}
|
||||||
|
assert_eq!(mode_of(&appended), 0o600);
|
||||||
|
// The whole point of the separate opener: opening again must not
|
||||||
|
// have truncated what was there.
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&appended).expect("read"),
|
||||||
|
"one\ntwo\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user