use std::fs;
use serde_json::json;
use tempfile::tempdir;
use super::ID;
use super::SHA;
use super::config;
use super::server::Reply;
use super::server::Server;
use crate::error::CanddingError;
use crate::hub::download::fetch;
use crate::registry::WeightsFormat;
const NOT_A_COMMIT: &str = "is not a commit of 40 lowercase hexadecimal characters";
fn hub_naming(
commit: &'static str,
listed: String,
) -> impl Fn(&str, &str) -> Reply + Send + Sync + 'static {
move |_, path| {
let path = path.split('?').next().unwrap_or(path);
if path == format!("/api/models/{ID}/revision/main") {
return Reply::new(200, json!({ "id": ID, "sha": commit }).to_string());
}
if path.starts_with(&format!("/api/models/{ID}/tree/")) {
let entry = |path: &str| json!({ "type": "file", "oid": "f", "size": 15, "path": path });
return Reply::new(
200,
json!([entry("config.json"), entry(&listed)]).to_string(),
);
}
Reply::new(200, "attacker bytes\n")
.header("ETag", "\"blob-evil\"")
.header("X-Repo-Commit", commit)
}
}
fn refused(commit: &'static str, listed: &str) -> (CanddingError, Vec<String>, usize) {
let server = Server::start(hub_naming(commit, listed.to_string()));
let cache = tempdir().unwrap();
let config = config(cache.path(), &server.url);
let error = fetch(&config, ID, "main", WeightsFormat::Safetensors).unwrap_err();
let written = fs::read_dir(cache.path())
.unwrap()
.flatten()
.filter(|entry| entry.file_name() != ".candding")
.count();
(error, server.requests(), written)
}
#[test]
fn a_listed_path_that_would_leave_the_snapshot_is_refused_before_any_write() {
for (listed, expected) in [
("/etc/candding/config.json", "is absolute"),
("1_Pooling\\config.json", "contains a backslash"),
("1_Pooling//config.json", "has an empty component"),
("config.json/", "has an empty component"),
("1_Pooling/../../../../escaped.json", "has a `..` component"),
("onnx/../../model.onnx", "has a `..` component"),
("./config.json", "has a `.` component"),
("1_Pooling/./config.json", "has a `.` component"),
("1_Pooling/.", "has a `.` component"),
] {
let (error, requests, written) = refused(SHA, listed);
match error {
CanddingError::UnsafeHubPath { id, path, reason } => {
assert_eq!((id.as_str(), path.as_str()), (ID, listed));
assert_eq!(reason, expected, "{listed}");
}
other => panic!("{listed}: {other:?}"),
}
assert!(
!requests.iter().any(|request| request.contains("/resolve/")),
"{listed}: {requests:?}"
);
assert_eq!(
written, 0,
"{listed}: something besides the lock was written"
);
}
}
#[test]
fn a_commit_that_is_not_40_lowercase_hex_characters_is_refused_before_the_listing() {
for commit in [
"../../../sha-escaped",
"0123456789abcdef0123456789abcdef0123456",
"0123456789abcdef0123456789abcdef012345678",
"0123456789ABCDEF0123456789abcdef01234567",
"0123456789abcdef0123456789abcdef01/../..",
] {
let (error, requests, written) = refused(commit, "config.json");
match error {
CanddingError::UnsafeHubPath { id, path, reason } => {
assert_eq!((id.as_str(), path.as_str()), (ID, commit));
assert_eq!(reason, NOT_A_COMMIT, "{commit}");
}
other => panic!("{commit}: {other:?}"),
}
assert_eq!(requests.len(), 1, "{commit}: {requests:?}");
assert_eq!(
written, 0,
"{commit}: something besides the lock was written"
);
}
}
#[test]
fn the_unsafe_path_error_names_the_model_the_path_and_the_reason() {
let (error, _, _) = refused(SHA, "1_Pooling/../x.json");
assert_eq!(
error.to_string(),
"the hub's answer for `org/model` names `1_Pooling/../x.json`, which has a `..` component; nothing was downloaded"
);
}