egui-037: reconcile current main and preserve valid catalog chains

This commit is contained in:
skylartaylor
2026-10-24 09:04:49 +02:00
11 changed files with 322 additions and 39 deletions
+48 -1
View File
@@ -263,14 +263,36 @@ fn newer(what: String) -> CatalogError {
CatalogError::Newer(format!("{what}; this version reads catalog format v{VERSION} and older"))
}
/// The most `resync` spends re-validating the suffixes of one line whose whole bytes are not valid
/// UTF-8 (a real fragment ends at any byte): one line of a modest malformed log that holds a start
/// of a record in every few bytes used to re-validate the whole remaining suffix at every attempt
/// (CWE-407). Past the budget the rest of the line stays a torn tail, which the damaged path keeps
/// and reports, same state as before, minus the CPU.
const RESYNC_BUDGET_BYTES: usize = 64 << 20; // 64 MiB
/// A line that doesn't decode but ends with a whole record: a fragment of a failed append followed
/// by the record that retried it (the retry re-encodes the same ops with the same `seq`s). Returns
/// the fragment's length and the record, if that record continues the log (`seq <= last + 1`).
/// The record's CRC must match, so damage is never mistaken for it.
fn resync(line: &[u8], last: u64) -> Option<(usize, (u64, Op))> {
const START: &[u8] = b"{\"seq\":";
// One record per line, so a line of valid UTF-8 holds a valid suffix at every record start and
// one validation covers every candidate; only a line that is not wholly valid re-checks, and
// then up to the budget rather than the suffix length times the candidate count.
let whole = std::str::from_utf8(line).ok();
let mut spent = 0usize;
(1..line.len().saturating_sub(START.len() - 1)).filter(|&i| line[i..].starts_with(START)).find_map(|i| {
let rec = std::str::from_utf8(&line[i..]).ok().map(|l| l.trim_end_matches('\r')).and_then(decode_record)?;
let text = match &whole {
Some(t) => &t[i..],
None => {
if spent >= RESYNC_BUDGET_BYTES {
return None;
}
spent = spent.saturating_add(line.len() - i);
std::str::from_utf8(&line[i..]).ok()?
}
};
let rec = decode_record(text.trim_end_matches('\r'))?;
(rec.0 <= last.saturating_add(1)).then_some((i, rec))
})
}
@@ -714,3 +736,28 @@ impl Drop for Journal {
}
}
}
#[cfg(test)]
mod tests_resync {
use super::*;
/// A torn first line may end at any byte and a failed append's retried records may follow its
/// own start `{"seq":` more than once: `resync` finds the CRC-valid record after a fragment of
/// many record starts. UTF-8 is validated once for a valid line, and for a line that isn't
/// wholly valid only up to the budget instead of the suffix length per candidate (CWE-407).
#[test]
fn resync_finds_the_retried_record_after_a_fragment_of_many_starts() {
let op = Op::Batch { ops: vec![] };
let record = encode_record(5, &op);
let mut line = Vec::from(b"\xf2" as &[u8]); // an invalid byte, so the whole line is not UTF-8
for i in 0..1024 {
line.extend_from_slice(format!("{{\"seq\":{},\"crc\":{{", i).as_bytes());
}
let fragment = line.len();
line.extend_from_slice(record.as_bytes());
assert_eq!(resync(&line, 4).map(|(at, (seq, _))| (at, seq)), Some((fragment, 5)));
// a retry is still recognised past `last`: the fragment logs nothing valid, but the CRC
// matches, so the double `Some` is only reached with `last >= 4`
assert_eq!(resync(&line, 100).map(|(at, (seq, _))| (at, seq)), Some((fragment, 5)));
}
}
+67 -5
View File
@@ -48,7 +48,11 @@ pub fn reparent(k: &str, from: &str, to: &str) -> String {
/// name, its parents' and synonyms flat besides its path). Each once, cleaned.
pub fn from_file(flat: &[String], hierarchical: &[String]) -> Vec<String> {
let mut out: Vec<String> = hierarchical.iter().map(|k| clean(k)).collect();
out.extend(flat.iter().map(|k| clean(k)).filter(|k| !hierarchical.iter().any(|h| h.split(SEP).any(|level| same(level.trim(), k)))));
// One lowered set of every hierarchical level, not a search of `hierarchical` for each flat
// keyword: disjoint lists from sidecars made the old approach quadratic in both (CWE-407), and
// `same` allocates two lower-cased strings per comparison.
let levels: std::collections::HashSet<String> = hierarchical.iter().flat_map(|h| h.split(SEP).map(|l| l.trim().to_lowercase())).collect();
out.extend(flat.iter().map(|k| clean(k)).filter(|k| !levels.contains(&k.to_lowercase())));
dedupe(&mut out);
out
}
@@ -78,6 +82,9 @@ pub fn parse_keyword_list(text: &str) -> Result<Vec<(String, KeywordInfo)>> {
let mut out: Vec<(String, KeywordInfo)> = Vec::new();
// where each keyword is in `out`, by lower-case path (a search of `out` per line was quadratic)
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
// synonyms already accepted, by keyword (a search of each keyword's growing synonym list per
// line was quadratic and re-allocated both spellings every comparison — CWE-407)
let mut synonyms: std::collections::HashMap<usize, std::collections::HashSet<String>> = std::collections::HashMap::new();
// the keyword at each level above the line: its path and where it is in `out`
let mut above: Vec<(String, usize)> = Vec::new();
for (i, line) in text.split('\n').enumerate() {
@@ -103,9 +110,9 @@ pub fn parse_keyword_list(text: &str) -> Result<Vec<(String, KeywordInfo)>> {
if synonym.contains(SEP) {
return Err(bad(n, "“|” separates a keyword's levels: a synonym can't hold one"));
}
if let Some((_, info)) = out.get_mut(at)
&& !synonym.is_empty()
&& !info.synonyms.iter().any(|s| same(s, synonym))
if !synonym.is_empty()
&& synonyms.entry(at).or_default().insert(synonym.to_lowercase())
&& let Some((_, info)) = out.get_mut(at)
{
info.synonyms.push(synonym.to_string());
}
@@ -264,7 +271,14 @@ impl Catalog {
let mut chain: Vec<usize> = Vec::new();
let mut path = String::new();
let mut lower = String::new();
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
// Level paths hold their every prefix (`path` and `lower` both), so a photo's XMP
// keyword split far more levels than a list file accepts (`MAX_LEVELS`) made the
// stored prefixes quadratic in the level count: a keyword nests [MAX_LEVELS]
// levels here, as a list file would, and what lies below is left out (CWE-400).
for (part_n, part) in (1..).zip(k.split(SEP).map(str::trim).filter(|s| !s.is_empty())) {
if part_n > MAX_LEVELS {
break;
}
if !path.is_empty() {
path.push(SEP);
lower.push(SEP);
@@ -505,7 +519,24 @@ impl Catalog {
}
}
let mut file = KeywordListFile::default();
// The tree is capped at [`MAX_LEVELS`] (its level rows hold every level's prefix twice, so a
// synthetic keyword past that depth made the stored bytes quadratic); whatever lies deeper
// comes from the photos' and list's own spellings instead, named unwritable as before.
let mut over_deep: std::collections::HashSet<String> = Default::default();
for k in self.listed_keywords().map(|k| &k.path) {
if k.split(SEP).count() > MAX_LEVELS {
over_deep.insert(k.clone());
}
}
for p in self.photos().filter(|p| p.in_library()) {
for k in &p.meta.keywords {
if clean(k).split(SEP).count() > MAX_LEVELS {
over_deep.insert(k.clone());
}
}
}
write(self, &self.keyword_tree(), 0, &mut file);
file.unwritable.extend(over_deep);
file
}
@@ -1470,4 +1501,35 @@ mod tests {
let s = c.keyword_suggestions(&[], "E", 5);
assert_eq!(s, ["eagle", "beach", "parade"], "prefix matches before substring matches");
}
/// A flat keyword already covered by a hierarchical level only in another spelling is not kept
/// again, whatever the size of either list (the per-flat-keyword search of every level, twice
/// lower-cased per comparison, was quadratic: CWE-407).
#[test]
fn from_file_matches_levels_in_any_case() {
let flat = ["Rome".to_string(), "Seaside".to_string()];
let hierarchical = ["travel|ITALY|rome".to_string()];
assert_eq!(from_file(&flat, &hierarchical), ["travel|ITALY|rome", "Seaside"]);
}
/// The same synonym, in any spelling, is kept once (the search of each keyword's growing
/// synonym list per line was quadratic, with lower-cased copies made per comparison).(CWE-407)
#[test]
fn parse_keyword_list_keeps_a_synonym_once() {
let read = parse_keyword_list("Travel\n\t{Trip}\n\t{journey}\n\t{TRIP}\n").unwrap();
assert_eq!(read.len(), 1);
assert_eq!(read[0].1.synonyms, ["Trip", "journey"]);
}
/// A keyword split into far more levels than a list file accepts is cut at [`MAX_LEVELS`] in the
/// tree, whose level rows hold every prefix twice: unbounded it made the stored bytes quadratic
/// in the level count of one short keyword (CWE-400).
#[test]
fn keyword_tree_caps_a_keyword_at_the_list_depth() {
let deep: Vec<String> = (0..4096).map(|i| format!("k{i}")).collect();
let joined = deep.join("|");
let (c, _) = lib(&[&[joined.as_str()]]);
let deepest = tree_paths(&c.keyword_tree()).iter().map(|(p, _)| p.split(SEP).count()).max().unwrap_or(0);
assert_eq!(deepest, MAX_LEVELS);
}
}
+30 -15
View File
@@ -1066,26 +1066,41 @@ impl Catalog {
}
st.photos.len() >= 2
});
let parents: Vec<(AlbumId, Option<AlbumId>)> = self.albums.values().map(|a| (a.id, a.parent)).collect();
for (id, parent) in parents {
let Some(p) = parent else { continue };
let mut cur = Some(p);
let mut steps = 0usize;
let ok = loop {
match cur {
None => break true,
Some(c) if c == id || steps > self.albums.len() => break false,
Some(c) => match self.albums.get(&c) {
Some(a) if a.folder => cur = a.parent,
_ => break false,
},
let albums: Vec<AlbumId> = self.albums.keys().copied().collect();
// Validate each parent link once (CWE-407), caching only chains that now reach a root.
// A cached bad verdict is not enough: skipping its ancestors would leave their broken
// links/cycle intact when a lower-id child was the first to walk into that chain.
let mut valid: std::collections::HashSet<AlbumId> = std::collections::HashSet::new();
for id in albums {
let mut walked: Vec<AlbumId> = Vec::new();
let broken = {
let mut cur = id;
let mut on: std::collections::HashSet<AlbumId> = std::collections::HashSet::new();
loop {
if valid.contains(&cur) {
break None;
}
if !on.insert(cur) {
// Detach one actual cycle node, not the child that first reached it.
break self.albums.get(&cur).and_then(|a| a.parent).map(|parent| (cur, parent));
}
walked.push(cur);
let Some(parent) = self.albums.get(&cur).and_then(|a| a.parent) else { break None };
if !self.albums.get(&parent).is_some_and(|a| a.folder) {
break Some((cur, parent));
}
cur = parent;
}
steps += 1;
};
if !ok && let Some(a) = self.albums.get_mut(&id) {
if let Some((id, p)) = broken
&& let Some(a) = self.albums.get_mut(&id)
{
changed.push(format!("album {}: its folder {} is missing, not a folder or inside it: moved to the top level", id.0, p.0));
a.parent = None;
}
// Only visited nodes are cached, after every one can reach the repaired root or a
// previously validated chain. Unvisited ancestors never inherit a guessed verdict.
valid.extend(walked);
}
let past = |last: Option<u64>| last.map_or(1, |v| v.saturating_add(1));
let (photo, album, stack) = (
+38
View File
@@ -45,6 +45,18 @@ pub trait Store: Send {
}
}
/// Appends and truncates open the name's own file, so anything else at the name is the library's
/// problem: a link planted there redirects the write outside the directory (CWE-59), and a device
/// hangs the open. Refused with an error the open can show, before anything is written through.
fn check_regular_directory_entry(path: &Path) -> io::Result<()> {
match std::fs::symlink_metadata(path) {
Ok(md) if md.is_file() => Ok(()),
Ok(md) => Err(io::Error::other(format!("{} is a {:?}, not a regular file", path.display(), md.file_type()))),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
/// Files in a directory.
pub struct FsStore {
dir: PathBuf,
@@ -137,6 +149,7 @@ impl Store for FsStore {
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
use std::io::Write;
if self.appender.as_ref().is_none_or(|(n, _)| n != name) {
check_regular_directory_entry(&self.path(name))?;
let existed = self.path(name).exists();
let f = std::fs::OpenOptions::new().create(true).append(true).open(self.path(name))?;
if !existed {
@@ -159,6 +172,7 @@ impl Store for FsStore {
if self.appender.as_ref().is_some_and(|(n, _)| n == name) {
self.appender = None;
}
check_regular_directory_entry(&self.path(name))?;
let f = match std::fs::OpenOptions::new().write(true).open(self.path(name)) {
Ok(f) => f,
// nothing to cut (an append that failed before creating the file)
@@ -266,4 +280,28 @@ mod tests_staging {
assert_eq!(std::fs::read(dir.join("catalog.log")).unwrap(), b"[1]");
let _ = std::fs::remove_dir_all(&dir);
}
/// The journal's append and truncate (which recovery uses) refuse a non-regular file at the
/// name instead of following it, so a link planted at `catalog.log` never receives library
/// bytes (CWE-59, Audit row 107); the snapshot write already removes such a link (#1502).
#[test]
fn append_and_truncate_refuse_a_link_at_the_name() {
let dir = std::env::temp_dir().join(format!("lc-log-link-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let victim = dir.join("victim.txt");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(&victim, b"keep").unwrap();
std::os::unix::fs::symlink(&victim, dir.join("catalog.log")).unwrap();
let mut store = FsStore::open(&dir).unwrap();
assert!(store.append("catalog.log", b"appended").is_err());
assert!(store.truncate("catalog.log", 0).is_err());
assert_eq!(std::fs::read(&victim).unwrap(), b"keep");
assert!(std::fs::symlink_metadata(dir.join("catalog.log")).unwrap().file_type().is_symlink());
// A regular file keeps working.
std::fs::remove_file(dir.join("catalog.log")).unwrap();
std::fs::write(dir.join("catalog.log"), b"abc").unwrap();
store.truncate("catalog.log", 1).unwrap();
assert_eq!(std::fs::read(dir.join("catalog.log")).unwrap(), b"a");
let _ = std::fs::remove_dir_all(&dir);
}
}
+68
View File
@@ -108,6 +108,74 @@ fn folder_loops_and_missing_parents_move_to_the_top() {
let _ = c.apply(Op::MoveAlbum { id: AlbumId(11), parent: Some(AlbumId(10)) });
}
/// Check the postcondition needed by every consumer that follows parent folders. This inspects
/// the repaired catalog, not its cache or a second implementation of the repair algorithm.
fn assert_all_album_chains_reach_roots(c: &Catalog) {
for album in c.albums.values() {
let mut seen = std::collections::HashSet::new();
let mut current = album;
while let Some(parent) = current.parent {
assert!(seen.insert(current.id), "album {} still reaches a cycle", album.id.0);
current = c.albums.get(&parent).expect("every retained parent exists");
assert!(current.folder, "every retained parent is a folder");
}
}
}
#[test]
fn a_child_visited_before_a_folder_cycle_keeps_its_content_and_gets_a_rooted_chain() {
let (c, ids) = sound();
let (mut c, report) = load_edited(&c, |v| {
// BTreeMap order visits10 first: cached Bad11/12 used to be skipped without repair.
v["albums"]["10"] = serde_json::to_value(album(10, Some(11), false, vec![ids[2]])).unwrap();
v["albums"]["11"] = serde_json::to_value(album(11, Some(12), true, vec![])).unwrap();
v["albums"]["12"] = serde_json::to_value(album(12, Some(11), true, vec![])).unwrap();
});
assert_all_album_chains_reach_roots(&c);
assert_eq!(c.album(AlbumId(10)).unwrap().parent, Some(AlbumId(11)), "the child remains in its actual folder");
assert_eq!(c.album(AlbumId(10)).unwrap().photos, vec![ids[2]], "repair preserves the child's photos");
assert_eq!(c.album(AlbumId(11)).unwrap().parent, None, "one cycle edge is repaired");
assert_eq!(c.album(AlbumId(12)).unwrap().parent, Some(AlbumId(11)), "the other folder edge is retained");
assert!(report.repaired.iter().any(|message| message.starts_with("album 11:")), "{:?}", report.repaired);
assert!(c.repair().is_empty(), "a second repair is idempotent");
}
#[test]
fn a_child_visited_before_a_folder_with_a_missing_parent_does_not_hide_the_bad_ancestor() {
let (c, ids) = sound();
let (mut c, report) = load_edited(&c, |v| {
v["albums"]["10"] = serde_json::to_value(album(10, Some(11), false, vec![ids[2]])).unwrap();
v["albums"]["11"] = serde_json::to_value(album(11, Some(999), true, vec![])).unwrap();
});
assert_all_album_chains_reach_roots(&c);
assert_eq!(c.album(AlbumId(10)).unwrap().parent, Some(AlbumId(11)));
assert_eq!(c.album(AlbumId(10)).unwrap().photos, vec![ids[2]]);
assert_eq!(c.album(AlbumId(11)).unwrap().parent, None, "repair the missing-parent edge itself");
assert!(report.repaired.iter().any(|message| message.starts_with("album 11:")), "{:?}", report.repaired);
assert!(c.repair().is_empty());
}
#[test]
fn a_long_valid_folder_chain_is_retained_when_its_child_is_visited_first() {
let (c, ids) = sound();
let (mut c, report) = load_edited(&c, |v| {
v["albums"]["10"] = serde_json::to_value(album(10, Some(100), false, vec![ids[2]])).unwrap();
for id in 100..612 {
v["albums"][id.to_string()] = serde_json::to_value(album(id, Some(id + 1), true, vec![])).unwrap();
}
v["albums"]["612"] = serde_json::to_value(album(612, None, true, vec![])).unwrap();
});
assert_all_album_chains_reach_roots(&c);
assert_eq!(c.album(AlbumId(10)).unwrap().parent, Some(AlbumId(100)));
assert_eq!(c.album(AlbumId(10)).unwrap().photos, vec![ids[2]]);
for id in 100..612 {
assert_eq!(c.album(AlbumId(id)).unwrap().parent, Some(AlbumId(id + 1)), "valid edge{id} remains");
}
assert_eq!(c.album(AlbumId(612)).unwrap().parent, None);
assert!(!report.repaired.iter().any(|message| message.starts_with("album ")), "valid parents must not be repaired: {:?}", report.repaired);
assert!(c.repair().is_empty());
}
/// Id counters left at the top (or behind the ids in use) by a damaged file: allocating no longer
/// overflows (a panic in debug builds) and never hands out an id already in use.
#[test]
+6 -2
View File
@@ -164,10 +164,14 @@ mod tests {
#[test]
fn appended_lines_accumulate() {
// Exclusive, private: the pid-derived name in a shared /tmp can be planted by another account
// (CWE-59), so an existing directory fails the test instead of it symlinking inside.
let dir = std::env::temp_dir().join(format!("lightcraft-testkit-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir(&dir).unwrap();
let log = dir.join("skips.jsonl");
let _ = std::fs::remove_file(&log);
if std::fs::symlink_metadata(&log).is_ok() {
std::fs::remove_file(&log).unwrap();
}
let threads: Vec<_> = (0..8)
.map(|i| {
let log = log.clone();
+12 -6
View File
@@ -13,7 +13,7 @@ approved foundation publication. Earlier planning and source-only validation sta
are historical and are superseded by this status.
The mandatory `cargo xtask ci` passed all eight stages (fmt, strict clippy, HEIF, tests,
parity, layers, assets and WASM). Its log reports 4,739 passing test invocations, zero failures
parity, layers, assets and WASM). Its log reports 4,747 passing test invocations, zero failures
and 40 ignored tests. The CI prerequisite report separately lists 149 checks that returned
without exercising their checks: corpus 137, models 8, fonts 2, GPU 1 and system 1. These are
coverage gaps, not qualified behavior. Canonical source identities were resolved through local
@@ -24,11 +24,17 @@ re-resolves the lexical parent and refuses a changed destination before encoding
The two standalone packaging helpers now default to the same craft-fonts pin as release CI;
explicit font-directory overrides and existing checkouts retain their behavior.
This qualification uses source base `98702b5c446ce2791b0aab92dab65cf9da7220b7`.
Observed main `4ae3089420cb57d4c839e51a735d4f48271cbf17` adds a disjoint catalog security
fix. Integration must preserve it and pass the required gates before landing. Actual visible
UI, native platform/IME/PRIMARY, browser behavior, final release-package notices and paired
performance acceptance remain open. No branch push or PR publication is authorized.
The current-main integration includes `4ae3089420cb57d4c839e51a735d4f48271cbf17` and
preserves its catalog hardening and other changes. Independent review caught a bad-chain
memoization error: a descendant visited before a broken ancestor could be detached unnecessarily.
The repair caches only proven valid chains and removes the offending parent edge, preserving
valid descendants. Regressions cover child-before-cycle, child-before-missing-parent and a valid
513-folder chain through actual catalog snapshot/reopen paths. All nine focused catalog repair
tests passed before the full eight-stage CI run on the merged source. The only formatting change
after the source review wraps one let-chain condition; the lockfile remains unchanged.
Actual visible UI, native platform/IME/PRIMARY, browser behavior, final release-package notices
and paired performance acceptance remain open. This application branch has not been published.
## Historical plan and review revisions
+11 -3
View File
@@ -4,6 +4,10 @@ import json, socket, sys, time
from pathlib import Path
# Largest control reply we accept (a screenshot response can carry a base64 image), after twice
# that the connection is closed instead of buffering further.
MAX_RESPONSE = 128 << 20
def _jsonl_path(arg):
"""Resolve the CLI file argument; require an existing readable regular file."""
path = Path(arg).resolve()
@@ -19,11 +23,15 @@ def _jsonl_path(arg):
def call(method, params):
s = socket.create_connection(("127.0.0.1", 7980), timeout=180)
s.sendall((json.dumps({"id": 1, "method": method, "params": params}) + "\n").encode())
buf = b""
while not buf.endswith(b"\n"):
buf = bytearray()
while not (len(buf) and buf[-1:] == b"\n"):
c = s.recv(1 << 20)
if not c: break
buf += c
# a hostile server must not make a client buffer or pay quadratic copies forever (CWE-407)
if len(buf) + len(c) > MAX_RESPONSE:
s.close()
return {"ok": False, "error": f"response exceeds {MAX_RESPONSE} bytes"}
buf.extend(c)
return json.loads(buf)
for line in open(_jsonl_path(sys.argv[1])):
line = line.strip()
+6 -2
View File
@@ -18,8 +18,12 @@ if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then
fi
TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
KEYCHAIN="$TMP/lightcraft-signing.keychain-db"
CERT="$TMP/lightcraft-signing.p12"
# A private directory (mktemp -d): a predictable name in a shared TMPDIR can be precreated by another
# local account, and a plain redirect would then receive the private key archive (CWE-377). Encoded
# signing material is written only inside the 0700 directory.
CERTDIR="$(mktemp -d "$TMP/lightcraft-signing.XXXXXX")"
KEYCHAIN="$CERTDIR/lightcraft-signing.keychain-db"
CERT="$CERTDIR/lightcraft-signing.p12"
KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}"
trap 'rm -f "$CERT"' EXIT
+17 -3
View File
@@ -79,18 +79,32 @@ fi
mkdir -p "$DIR"
BASE="https://huggingface.co/$REPO/resolve/main"
# The bearer header goes through a private temporary file, never argv: on systems that expose other
# users' process arguments another local account could read the token from `ps` (CWE-214), but not a
# 0600 file. curl's config format: `header = "..."`.
if [ -n "$TOKEN" ]; then
AUTH_FILE=$(mktemp "${TMPDIR:-/tmp}/lightcraft-sam3-auth.XXXXXX")
trap 'rm -f "$AUTH_FILE"' EXIT
printf 'header = "Authorization: Bearer %s"\n' "$TOKEN" > "$AUTH_FILE"
fi
# fetch FILE DEST [resume]: HTTP errors never reach DEST (--fail writes no error page), and a
# resume the server refuses (416) starts over instead of counting as done.
fetch() {
file=$1 dest=$2 resume=${3:-}
if [ -n "$TOKEN" ]; then auth="Authorization: Bearer $TOKEN"; else auth="X-No-Auth: 1"; fi
code=$(curl --proto '=https' -fsSL ${resume:+-C -} -H "$auth" -w '%{http_code}' -o "$dest" "$BASE/$file") || true
if [ -n "$TOKEN" ]; then
CURL_AUTH=(--config "$AUTH_FILE")
else
# not secret (no token is sent); kept in argv only to mirror the authenticated call shape
CURL_AUTH=(-H "X-No-Auth: 1")
fi
code=$(curl --proto '=https' -fsSL ${resume:+-C -} "${CURL_AUTH[@]}" -w '%{http_code}' -o "$dest" "$BASE/$file") || true
case "$code" in
200|206) return 0 ;;
416)
echo "the partial download of $file doesn't match the server's; starting over" >&2
rm -f "$dest"
code=$(curl --proto '=https' -fsSL -H "$auth" -w '%{http_code}' -o "$dest" "$BASE/$file") || true
code=$(curl --proto '=https' -fsSL "${CURL_AUTH[@]}" -w '%{http_code}' -o "$dest" "$BASE/$file") || true
[ "$code" = 200 ] && return 0
echo "download of $REPO/$file failed ($code)" >&2; return 1 ;;
401|403) echo "access denied to $REPO/$file (has your request for facebook/sam3 been approved?)" >&2; return 1 ;;
+19 -2
View File
@@ -91,6 +91,23 @@ def dng_tags(cmf, refl, ssf):
return out
def rust_lit(s):
"""The body of a Rust string literal: text from a checkout becomes data, never code."""
out = []
for ch in s:
if ch == "\\": out.append("\\\\")
elif ch == '"': out.append('\\"')
elif ch == "\t": out.append("\\t")
elif ord(ch) < 0x20 or ord(ch) == 0x7F: out.append(f"\\u{{{ord(ch):x}}}")
else: out.append(ch)
return "".join(out)
def rust_comment(s):
"""One line of printable text: a raw newline in an external comment would introduce Rust code."""
return rust_lit(" ".join(str(s).split()))
def mat(m):
return "Mat3([" + ", ".join("[" + ", ".join(f"{v:.6}" for v in row) + "]" for row in m) + "])"
@@ -111,12 +128,12 @@ def main(root, out):
(cm1, fm1, e1), (cm2, fm2, e2) = dng_tags(cmf, refl, spectral(path, 3))
print(f"{f}: training dE76 A {e1:.3f}, D65 {e2:.3f}", file=sys.stderr)
rows.append(
f' Camera {{\n make: "{header["manufacturer"]}",\n model: "{header["model"]}",\n'
f' Camera {{\n make: "{rust_lit(header["manufacturer"])}",\n model: "{rust_lit(header["model"])}",\n'
f" color_matrix: [{mat(cm1)}, {mat(cm2)}],\n forward_matrix: [{mat(fm1)}, {mat(fm2)}],\n }},"
)
aliases = json.load(open(os.path.join(root, "data/aliases.json")))["data"]["camera"]["model"]
alias_rows = [
f' ("{make}", "{alias}", "{target["model"]}"), // {target.get("comments", "")}'
f' ("{rust_lit(make)}", "{rust_lit(alias)}", "{rust_lit(target["model"])}"), // {rust_comment(target.get("comments", ""))}'
for make, models in aliases.items()
for alias, target in models.items()
]