use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::config::StoragePolicy;
use crate::storage::{TokenRecord, TokenStore, build_token_store_read_only, merge_safer_record};
pub const BACKUP_DIRECTORY: &str = "token-import-backups";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ImportMode {
MergeMissing,
Replace,
}
impl ImportMode {
const fn name(self) -> &'static str {
match self {
Self::MergeMissing => "merge-missing",
Self::Replace => "replace",
}
}
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct Conflict {
pub id: String,
pub fields: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, PartialEq, Eq)]
pub struct ImportReport {
pub mode: &'static str,
pub dry_run: bool,
pub source_records: usize,
pub target_records_before: usize,
pub added: Vec<String>,
pub unchanged: Vec<String>,
pub conflicts: Vec<Conflict>,
pub replaced: Vec<Conflict>,
pub kept_revoked: Vec<String>,
pub missing_from_source: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub backup: Option<PathBuf>,
}
impl ImportReport {
#[must_use]
pub const fn writes(&self) -> bool {
!self.added.is_empty() || !self.replaced.is_empty()
}
}
pub fn read_source(path: &Path) -> Result<Vec<TokenRecord>, String> {
let metadata =
fs::metadata(path).map_err(|error| format!("cannot read {}: {error}", path.display()))?;
if metadata.is_file() {
return read_file(path);
}
let data = path.join("data");
let directory = if !holds_tokens(path) && holds_tokens(&data) {
data
} else {
path.to_path_buf()
};
if !holds_tokens(&directory) {
return Err(format!(
"{} holds no tokens.lino, tokens.bin or tokens.json",
path.display()
));
}
let mut records = read_directory(&directory)?;
let checkpoint = directory.join("tokens.json");
if checkpoint.is_file() {
merge_into(&mut records, read_json(&checkpoint)?);
}
Ok(records.into_values().collect())
}
fn holds_tokens(directory: &Path) -> bool {
["tokens.lino", "tokens.bin", "tokens.json"]
.iter()
.any(|name| directory.join(name).is_file())
}
fn read_directory(directory: &Path) -> Result<BTreeMap<String, TokenRecord>, String> {
let store = build_token_store_read_only(StoragePolicy::Both, directory)
.map_err(|error| format!("cannot decode tokens in {}: {error}", directory.display()))?;
let records = store.list().map_err(|error| error.to_string())?;
Ok(records
.into_iter()
.map(|record| (record.id.clone(), record))
.collect())
}
fn read_json(path: &Path) -> Result<Vec<TokenRecord>, String> {
let bytes =
fs::read(path).map_err(|error| format!("cannot read {}: {error}", path.display()))?;
serde_json::from_slice(&bytes)
.map_err(|error| format!("{} is not a token export: {error}", path.display()))
}
fn read_file(path: &Path) -> Result<Vec<TokenRecord>, String> {
let extension = path
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or_default();
let canonical = match extension {
"json" => return read_json(path),
"lino" => "tokens.lino",
"bin" => "tokens.bin",
_ => {
return Err(format!(
"{} is not a .lino, .bin or .json token file",
path.display()
));
}
};
let workspace = tempfile::tempdir().map_err(|error| error.to_string())?;
fs::copy(path, workspace.path().join(canonical))
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
Ok(read_directory(workspace.path())?.into_values().collect())
}
fn merge_into(records: &mut BTreeMap<String, TokenRecord>, more: Vec<TokenRecord>) {
for record in more {
records
.entry(record.id.clone())
.and_modify(|current| merge_safer_record(current, &record))
.or_insert(record);
}
}
fn differing_fields(left: &TokenRecord, right: &TokenRecord) -> Vec<String> {
let as_map = |record: &TokenRecord| match serde_json::to_value(record) {
Ok(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
let (left, right) = (as_map(left), as_map(right));
let keys: BTreeSet<&String> = left.keys().chain(right.keys()).collect();
keys.into_iter()
.filter(|key| left.get(*key) != right.get(*key))
.cloned()
.collect()
}
#[derive(Clone, Copy, Debug)]
pub struct ImportOptions<'a> {
pub mode: ImportMode,
pub dry_run: bool,
pub ids: &'a [String],
pub backup_directory: Option<&'a Path>,
}
pub fn import(
target: &dyn TokenStore,
source: Vec<TokenRecord>,
options: ImportOptions<'_>,
) -> Result<ImportReport, String> {
let current: BTreeMap<String, TokenRecord> = target
.list()
.map_err(|error| format!("cannot read the target token store: {error}"))?
.into_iter()
.map(|record| (record.id.clone(), record))
.collect();
let mut source: BTreeMap<String, TokenRecord> = source
.into_iter()
.map(|record| (record.id.clone(), record))
.collect();
let mut report = ImportReport {
mode: options.mode.name(),
dry_run: options.dry_run,
source_records: source.len(),
target_records_before: current.len(),
..ImportReport::default()
};
if !options.ids.is_empty() {
let wanted: BTreeSet<&String> = options.ids.iter().collect();
report.missing_from_source = wanted
.iter()
.filter(|id| !source.contains_key(**id))
.map(|id| (*id).clone())
.collect();
source.retain(|id, _| wanted.contains(id));
}
let mut writes = Vec::new();
for (id, incoming) in source {
let Some(existing) = current.get(&id) else {
report.added.push(id);
writes.push(incoming);
continue;
};
if *existing == incoming {
report.unchanged.push(id);
continue;
}
let fields = differing_fields(existing, &incoming);
match options.mode {
ImportMode::MergeMissing => report.conflicts.push(Conflict { id, fields }),
ImportMode::Replace => {
let mut replacement = incoming;
merge_safer_record(&mut replacement, existing);
if existing.revoked {
report.kept_revoked.push(id.clone());
}
if replacement == *existing {
report.unchanged.push(id);
continue;
}
report.replaced.push(Conflict { id, fields });
writes.push(replacement);
}
}
}
if options.dry_run || writes.is_empty() {
return Ok(report);
}
if !report.replaced.is_empty() {
let directory = options
.backup_directory
.ok_or("--replace needs a backup location for the target's records")?;
report.backup = Some(write_backup(directory, ¤t)?);
}
for record in writes {
let id = record.id.clone();
target.put(record).map_err(|error| {
format!(
"importing {id} failed: {error}; records before it were imported, rerun to finish"
)
})?;
}
Ok(report)
}
fn write_backup(
directory: &Path,
records: &BTreeMap<String, TokenRecord>,
) -> Result<PathBuf, String> {
let path = directory.join(format!(
"tokens-{}-{}.json",
chrono::Utc::now().format("%Y%m%dT%H%M%SZ"),
&uuid::Uuid::new_v4().simple().to_string()[..8]
));
let records: Vec<&TokenRecord> = records.values().collect();
let bytes = serde_json::to_vec_pretty(&records).map_err(|error| error.to_string())?;
fs::create_dir_all(directory)
.and_then(|()| crate::durable_file::atomic_write_owner_only(&path, &bytes))
.map_err(|error| {
format!("cannot back up the target's records; nothing imported: {error}")
})?;
Ok(path)
}
#[must_use]
pub fn render(report: &ImportReport) -> String {
use std::fmt::Write as _;
let verb = if report.dry_run { "would add" } else { "added" };
let mut text = format!(
"{verb} {} record(s), {} unchanged, {} conflict(s) left alone, {} replaced ({} source, {} target before; mode {})\n",
report.added.len(),
report.unchanged.len(),
report.conflicts.len(),
report.replaced.len(),
report.source_records,
report.target_records_before,
report.mode,
);
for id in &report.added {
let _ = writeln!(text, " + {id}");
}
for conflict in &report.conflicts {
let _ = writeln!(
text,
" ! {} differs in {}; pass --replace to overwrite",
conflict.id,
conflict.fields.join(", ")
);
}
for conflict in &report.replaced {
let _ = writeln!(text, " ~ {} ({})", conflict.id, conflict.fields.join(", "));
}
for id in &report.kept_revoked {
let _ = writeln!(text, " - {id} stays revoked");
}
for id in &report.missing_from_source {
let _ = writeln!(text, " ? {id} is not in the source");
}
if let Some(backup) = &report.backup {
let _ = writeln!(text, "previous records saved to {}", backup.display());
}
text
}
#[derive(Clone, Copy, Debug)]
pub struct ImportCommand<'a> {
pub from: &'a Path,
pub ids: &'a [String],
pub replace: bool,
pub dry_run: bool,
pub json: bool,
}
#[must_use]
pub fn run_cli(
target: &dyn TokenStore,
data_dir: &Path,
command: &ImportCommand<'_>,
) -> std::process::ExitCode {
use std::process::ExitCode;
let outcome = read_source(command.from).and_then(|source| {
import(
target,
source,
ImportOptions {
mode: if command.replace {
ImportMode::Replace
} else {
ImportMode::MergeMissing
},
dry_run: command.dry_run,
ids: command.ids,
backup_directory: Some(&data_dir.join(BACKUP_DIRECTORY)),
},
)
});
let report = match outcome {
Ok(report) => report,
Err(error) => {
eprintln!("error: {error}");
return ExitCode::from(1);
}
};
if command.json {
println!(
"{}",
serde_json::to_string_pretty(&report).unwrap_or_default()
);
} else {
print!("{}", render(&report));
}
if report.missing_from_source.is_empty() && report.conflicts.is_empty() {
ExitCode::SUCCESS
} else {
ExitCode::from(2)
}
}
#[must_use]
pub fn refusal(server: &crate::managed_server::ResolvedServer) -> String {
format!(
"`tokens import` writes the token store of the machine it runs on, and {} is selected. \
Run it on that deployment — `docker exec <container> router tokens import ...` for a \
containerised one — or pass --local to import into this machine's store.",
server.base_url
)
}