Wire it up

This commit is contained in:
Andras Schmelczer 2026-09-19 17:06:39 +01:00
commit 8a85a68dbb
5 changed files with 336 additions and 55 deletions

View file

@ -20,7 +20,7 @@ pub struct AppState {
impl AppState {
pub async fn try_new(config: Config) -> Result<Self> {
let broadcasts = Broadcasts::new(&config.server);
let database = Database::try_new(&config.database, &broadcasts).await?;
let database = Database::try_new(&config.database).await?;
let cursors: Cursors = Cursors::new(&config.database, &broadcasts);
Cursors::start_background_task(cursors.clone());

View file

@ -5,7 +5,7 @@ use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use log::{debug, error};
use log::debug;
use serde::Serialize;
use thiserror::Error;
use ts_rs::TS;

View file

@ -93,18 +93,22 @@ pub async fn create_server(config: Config) -> Result<()> {
fn get_authed_routes(app_state: AppState) -> Router<AppState> {
Router::new()
.route(
"/vaults/:vault_id/manifest",
get(endpoints::get_manifest::get_manifest).put(endpoints::put_manifest::put_manifest),
"/vaults/:vault_id/file_manifest",
get(endpoints::get_file_manifest::get_file_manifest)
.put(endpoints::put_file_manifest::put_file_manifest),
)
.route(
"/vaults/:vault_id/state",
get(endpoints::snapshot::snapshot),
"/vaults/:vault_id/vault_snapshot",
get(endpoints::vault_snapshot::vault_snapshot),
)
.route(
"/vaults/:vault_id/events_since",
get(endpoints::events::events),
)
.route("/vaults/:vault_id/events", get(endpoints::events::events))
.route(
"/vaults/:vault_id/documents/:document_id",
get(endpoints::fetch_latest_document_version::fetch_latest_document_version)
.put(endpoints::push_document::push_document),
.put(endpoints::put_file_content::put_file_content),
)
.route(
"/vaults/:vault_id/documents/:document_id/versions/:vault_update_id/content",

View file

@ -1,6 +1,4 @@
pub mod dedup_paths;
pub mod find_first_available_path;
pub mod log_level;
pub mod normalize;
pub mod portable_path;
pub mod rotating_file_writer;
pub mod sanitize_path;

View file

@ -0,0 +1,223 @@
use crate::app_state::database::models::FileManifestEntries;
use anyhow::{Result, bail, ensure};
use std::collections::BTreeMap;
use unicode_normalization::UnicodeNormalization;
pub const INTERNAL_DIRECTORY: &str = ".vault-link-sync";
// Use the same locale-independent Unicode upper-case mapping in the client.
// NFC -> uppercase -> NFC also catches ß/SS and final/ordinary sigma aliases.
pub fn path_key(path: &str) -> String {
path.nfc()
.collect::<String>()
.to_uppercase()
.nfc()
.collect()
}
pub fn validate_file_manifest(entries: &FileManifestEntries) -> Result<()> {
let mut nodes: BTreeMap<String, (String, bool)> = BTreeMap::new();
for path in entries.values() {
ensure!(
!path.is_empty() && path.len() <= 240,
"Path must contain 1–240 UTF-8 bytes"
);
ensure!(
path.nfc().collect::<String>() == *path,
"Paths must be NFC normalized"
);
let parts: Vec<_> = path.split('/').collect();
ensure!(
path_key(parts[0]) != path_key(INTERNAL_DIRECTORY),
"Reserved sync directory"
);
for (index, part) in parts.iter().enumerate() {
ensure!(
!part.is_empty() && *part != "." && *part != "..",
"Invalid path component"
);
ensure!(!part.ends_with(['.', ' ']), "Trailing dot or space in path");
ensure!(
!part
.chars()
.any(|c| c.is_control() || c == '\\' || "<>:\"|?*".contains(c)),
"Non-portable filename"
);
let stem = part
.split('.')
.next()
.unwrap_or_default()
.trim_end_matches(' ')
.to_uppercase();
ensure!(
!matches!(
stem.as_str(),
"CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
),
"Reserved filename"
);
if stem.starts_with("COM") || stem.starts_with("LPT") {
ensure!(
!matches!(
&stem[3..],
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
),
"Reserved filename"
);
}
let prefix = parts[..=index].join("/");
let is_file = index == parts.len() - 1;
if let Some((spelling, previous_is_file)) = nodes.get(&path_key(&prefix)) {
if *spelling != prefix || *previous_is_file || is_file {
bail!("Conflicting path: {path}");
}
} else {
nodes.insert(path_key(&prefix), (prefix, is_file));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{FileManifestEntries, path_key, validate_file_manifest};
use uuid::Uuid;
fn manifest(paths: &[&str]) -> FileManifestEntries {
paths
.iter()
.enumerate()
.map(|(index, path)| (Uuid::from_u128(index as u128), (*path).to_owned()))
.collect()
}
#[test]
fn path_keys_normalize_unicode_and_case_aliases() {
for (path, expected) in [
("Notes/Café.md", "NOTES/CAFÉ.MD"),
("notes/Cafe\u{301}.md", "NOTES/CAFÉ.MD"),
("Straße.md", "STRASSE.MD"),
("σς.md", "ΣΣ.MD"),
] {
assert_eq!(path_key(path), expected, "path: {path:?}");
}
}
#[test]
fn accepts_empty_manifests_and_portable_paths_with_shared_directories() {
validate_file_manifest(&manifest(&[])).unwrap();
let entries = manifest(&[
"Notes/Café.md",
"Notes/日記.md",
"Notes/Archive/old.md",
".gitkeep",
"COM10.txt",
"LPT0.txt",
".vault-link-sync-backup/state.json",
"Notes/.vault-link-sync/state.json",
]);
validate_file_manifest(&entries).unwrap();
}
#[test]
fn enforces_the_path_length_limit_in_utf8_bytes() {
for path in ["a".repeat(240), "é".repeat(120)] {
validate_file_manifest(&manifest(&[&path])).unwrap();
let too_long = format!("{path}a");
assert!(
validate_file_manifest(&manifest(&[&too_long])).is_err(),
"accepted a path over 240 bytes: {too_long:?}"
);
}
}
#[test]
fn rejects_invalid_components_characters_and_non_normalized_paths() {
for path in [
"",
"/notes.md",
"notes.md/",
"notes//a.md",
"./notes.md",
"notes/../a.md",
"notes./a.md",
"notes /a.md",
"Cafe\u{301}.md",
] {
assert!(
validate_file_manifest(&manifest(&[path])).is_err(),
"accepted invalid path: {path:?}"
);
}
for character in ['\\', '<', '>', ':', '"', '|', '?', '*', '\0', '\u{85}'] {
let path = format!("notes/a{character}b.md");
assert!(
validate_file_manifest(&manifest(&[&path])).is_err(),
"accepted non-portable character in path: {path:?}"
);
}
}
#[test]
fn rejects_reserved_sync_and_windows_names() {
for path in [
".vault-link-sync",
".VAULT-LINK-SYNC/state.json",
"con",
"prn.txt",
"AUX.md",
"nul .txt",
"CONIN$",
"conout$.log",
"com1.txt",
"LPT9",
"COM¹.txt",
"lpt²/file.md",
"COM³.txt",
] {
assert!(
validate_file_manifest(&manifest(&[path])).is_err(),
"accepted reserved path: {path:?}"
);
}
}
#[test]
fn rejects_path_conflicts_in_either_entry_order() {
for [first, second] in [
["notes.md", "notes.md"],
["Notes.md", "notes.md"],
["Straße.md", "STRASSE.md"],
["ς.md", "σ.md"],
["Notes/a.md", "notes/b.md"],
["notes", "notes/a.md"],
] {
for paths in [[first, second], [second, first]] {
assert!(
validate_file_manifest(&manifest(&paths)).is_err(),
"accepted conflicting paths: {paths:?}"
);
}
}
}
}