using Invercargill; using Invercargill.DataStructures; using Usm; namespace UsmWeb { /** The kind of a discovered repository directory. */ public enum RepositoryKind { /** A USM source repository: `.usmr` + `public/PACKAGES.usml` + `.usmc` packages. */ USM, /** A DCBA artifact repository: `.dcbar` + `public/ARTIFACTS.usml` + `.dcba` artifacts. */ DCBA; /** The wire/display name used on generated pages. */ public string to_string() { switch (this) { case RepositoryKind.USM: return "usm"; case RepositoryKind.DCBA: return "dcba"; default: assert_not_reached(); } } } /** * One on-disk repository discovered under the configured parent * directory, built on libusm via {@link Usm.Repository.from_file}. * * A USM entry is a subdirectory holding a `.usmr` beside a `public/` * directory with `PACKAGES.usml`; a DCBA entry is the artifact-repo * counterpart holding a `.dcbar` (the same JSON shape) beside a * `public/` directory with `ARTIFACTS.usml` and `.dcba` files. The * two kinds share the loading machinery but expose disjoint members; * reaching for a member of the other kind throws instead of * silently answering nonsense. * * Each entry tracks its descriptor (`.usmr`/`.dcbar`) and listing * (`PACKAGES.usml`/`ARTIFACTS.usml`) mtimes independently, so one * repository can be rebuilt and re-read without disturbing the * others, and both file roles reload when their own mtime moves. */ public class RepositoryEntry : Object { /** The repository's directory (absolute). */ public string dir_path { get; private set; } /** * The absolute path of the `.usmr` (USM entries) or `.dcbar` * (DCBA entries) inside {@link dir_path}. */ public string descriptor_path { get; private set; } /** This repository's kind (source or artifact). */ public RepositoryKind kind { get; private set; } /** * Compatibility alias for {@link descriptor_path} — the endpoint * serving `repo.usmr` reads the raw descriptor through this name. */ public string usmr_path { get { return descriptor_path; } } private Repository? repository; private RepositoryListing? listing; private DcbaListing? dcba_listing; private int64 descriptor_mtime; private int64 listing_mtime; public RepositoryEntry.usm(string dir_path, string usmr_path) { this.dir_path = dir_path; this.kind = RepositoryKind.USM; this.descriptor_path = usmr_path; } public RepositoryEntry.dcba(string dir_path, string dcbar_path) { this.dir_path = dir_path; this.kind = RepositoryKind.DCBA; this.descriptor_path = dcbar_path; } /** Whether the descriptor and this kind's listing are currently loaded. */ public bool is_loaded { get { if (kind == RepositoryKind.DCBA) { return repository != null && dcba_listing != null; } return repository != null && listing != null; } } /** * Reloads the descriptor and listing when either file changed on * disk (or was never loaded); a failure throws while keeping the * previously loaded snapshot, so an in-flight rebuild cannot lose * what is already held. */ public void ensure_loaded() throws Error { int64 current_descriptor_mtime = file_mtime(descriptor_path); int64 current_listing_mtime = file_mtime(current_listing_path()); if (is_loaded && current_descriptor_mtime == descriptor_mtime && current_listing_mtime == listing_mtime) { return; } if (repository == null || current_descriptor_mtime != descriptor_mtime) { repository = new Repository.from_file(descriptor_path); descriptor_mtime = current_descriptor_mtime; } if (kind == RepositoryKind.DCBA) { if (dcba_listing == null || current_listing_mtime != listing_mtime) { var file = File.new_for_path(current_listing_path()); dcba_listing = new DcbaListing.from_stream(new DataInputStream(file.read())); listing_mtime = current_listing_mtime; } } else if (listing == null || current_listing_mtime != listing_mtime) { var file = File.new_for_path(current_listing_path()); listing = new RepositoryListing.from_stream(new DataInputStream(file.read())); listing_mtime = current_listing_mtime; } } /** The loaded repository metadata (name, summary, signing key). */ public Repository repo() throws Error { ensure_loaded(); return (!)repository; } /** * The loaded `PACKAGES.usml` entries, in listing order. USM * entries only — DCBA entries answer with * {@link dcba_listing_entries}. */ public Enumerable listing_entries() throws Error { if (kind == RepositoryKind.DCBA) { throw_wrong_kind("PACKAGES.usml entries"); } ensure_loaded(); return ((!)listing).entries; } /** * The loaded `ARTIFACTS.usml` entries, in listing order. DCBA * entries only — USM entries answer with {@link listing_entries}. */ public Enumerable dcba_listing_entries() throws Error { if (kind != RepositoryKind.DCBA) { throw_wrong_kind("ARTIFACTS.usml entries"); } ensure_loaded(); return ((!)dcba_listing).entries; } /** * The listing entry whose package file is `file`, or null. USM * entries only. */ public RepositoryListingEntry? find_entry(string file) throws Error { foreach (var entry in listing_entries()) { if (Path.get_basename(entry.path) == file) { return entry; } } return null; } /** * The artifact entry whose package name and version match `name` * and `version` exactly (a plain string comparison on both, per * the DCBA specification — listing strings never mutate through * a {@link Usm.Version} round trip), or null when this repository * carries no artifact for that package. DCBA entries only. */ public DcbaListingEntry? find_dcba_artifact(string name, string version) throws Error { foreach (var entry in dcba_listing_entries()) { if (entry.name == name && entry.version == version) { return entry; } } return null; } /** * The absolute path of the verbatim-served `PACKAGES.usml`. USM * entries only — DCBA entries answer with * {@link artifacts_listing_location}. */ public string listing_location() throws Error { if (kind == RepositoryKind.DCBA) { throw_wrong_kind("PACKAGES.usml location"); } return Path.build_filename(dir_path, "public", Usm.RepositoryClient.LISTING_NAME); } /** * The absolute path of the verbatim-served `ARTIFACTS.usml`. DCBA * entries only — USM entries answer with {@link listing_location}. */ public string artifacts_listing_location() throws Error { if (kind != RepositoryKind.DCBA) { throw_wrong_kind("ARTIFACTS.usml location"); } return Path.build_filename(dir_path, "public", Usm.RepositoryClient.DCBA_LISTING_NAME); } /** * The path of this kind's listing file, unguarded: both kinds * keep their listing under `public/`, so {@link ensure_loaded} * watches one location per kind. */ private string current_listing_path() { var listing_name = kind == RepositoryKind.DCBA ? Usm.RepositoryClient.DCBA_LISTING_NAME : Usm.RepositoryClient.LISTING_NAME; return Path.build_filename(dir_path, "public", listing_name); } /** * The actual descriptor filename inside the repository * directory (`name.usmr` or `name.dcbar`), for messages and * sibling-route naming. */ public string descriptor_basename() { return Path.get_basename(descriptor_path); } /** * The absolute path of a package archive inside `public/`. USM * entries only — DCBA entries answer with * {@link artifact_location}. */ public string package_location(string file) throws Error { if (kind == RepositoryKind.DCBA) { throw_wrong_kind("package location"); } return Path.build_filename(dir_path, "public", file); } /** * The absolute path of a `.dcba` artifact inside `public/`. DCBA * entries only — USM entries answer with {@link package_location}. */ public string artifact_location(string file) throws Error { if (kind != RepositoryKind.DCBA) { throw_wrong_kind("artifact location"); } return Path.build_filename(dir_path, "public", file); } /** * Raises the cross-kind misuse error: every member documented as * kind-specific guards against being reached through the wrong * kind, so a DCBA entry never answers for a `PACKAGES.usml` and * vice versa. */ private void throw_wrong_kind(string member) throws Error { throw new UsmWebError.INVALID_CONFIGURATION( @"Repository at \"$dir_path\" is a $kind repository — $member is only available for $(kind == RepositoryKind.DCBA ? RepositoryKind.USM : RepositoryKind.DCBA) repositories"); } private static int64 file_mtime(string path) { try { var info = File.new_for_path(path).query_info( FileAttribute.TIME_MODIFIED, FileQueryInfoFlags.NONE); return (int64) info.get_attribute_uint64(FileAttribute.TIME_MODIFIED); } catch (Error e) { return -1; } } } /** * Read-side access to the on-disk repositories this app browses and * serves, in a mixed layout. * * The repositories live under {@link UsmWebConfig.repo_dir}: every * subdirectory containing a `.usmr` is a USM source repository * (`.usmc` packages listed in `public/PACKAGES.usml`) and every * subdirectory containing a `.dcbar` is a DCBA artifact repository * (`.dcba` files listed in `public/ARTIFACTS.usml`); both are * discovered by the same scan, or only those named by * {@link UsmWebConfig.repositories} are used when that list is * configured — the list applies to both kinds. A repository is * addressed by the name in its descriptor, which is also its routing * segment (`/repo//…`). A directory holding both a `.usmr` * and a `.dcbar` is a configuration error. A legacy * single-repository layout with the `.usmr` directly inside the * configured directory is still served as one USM repository. * * {@link entries} returns every loadable repository of either kind, * in discovery order; {@link usm_entries} and {@link dcba_entries} * filter it by kind for callers that iterate one world at a time * (the home package table and search only see source repositories). * * Discovery re-runs on every {@link ensure_loaded}, so repositories * added or removed on disk are picked up without a restart; each * repository's descriptor and listing reload independently when * their mtimes change. A repository that fails to (re)load is * skipped with a warning rather than taking the whole app down. */ public class RepositoryService : Object { private UsmWebConfig config = Inversion.inject(); private Vector discovered = new Vector(); private Vector loaded = new Vector(); private Dictionary entries_by_dir = new Dictionary(); private string? legacy_dir; /** * Discovers the repositories under the configured parent directory * and (re)loads each one. Throws when nothing is configured or * discovered, or when nothing discovered can be loaded; individual * failures only warn and drop that repository until it loads again. */ public void ensure_loaded() throws Error { var current = discover(); var still_current = new Vector(); Error? first_failure = null; foreach (var entry in current) { try { entry.ensure_loaded(); still_current.add(entry); } catch (Error e) { warning("[usm-web] Repository at %s failed to load: %s\n", entry.dir_path, e.message); if (first_failure == null) { first_failure = e; } } } var unique = new Vector(); var seen_names = new Dictionary(); foreach (var entry in still_current) { var name = entry.repo().name; RepositoryEntry? seen = null; seen_names.try_get(name, out seen); if (seen != null) { warning("[usm-web] \"%s\" is also served by %s — keeping %s\n", name, entry.dir_path, ((!)seen).dir_path); continue; } seen_names.set(name, entry); unique.add(entry); } discovered.clear(); discovered.add_all(current); loaded.clear(); loaded.add_all(unique); if (loaded.length == 0) { if (first_failure != null) { throw first_failure; } throw new UsmWebError.INVALID_CONFIGURATION( @"No repositories found under \"$(config.repo_dir)\" — is the parent directory mounted?"); } } /** The currently loadable repositories, in discovery order. */ public Enumerable entries() throws Error { ensure_loaded(); return loaded; } /** * The currently loadable USM source repositories, in discovery * order — the home package table, search and package detail * flows iterate these. Same load/throw contract as * {@link entries}. */ public Enumerable usm_entries() throws Error { var result = new Vector(); foreach (var entry in entries()) { if (entry.kind == RepositoryKind.USM) { result.add(entry); } } return result; } /** * The currently loadable DCBA artifact repositories, in discovery * order. Same load/throw contract as {@link entries}. */ public Enumerable dcba_entries() throws Error { var result = new Vector(); foreach (var entry in entries()) { if (entry.kind == RepositoryKind.DCBA) { result.add(entry); } } return result; } /** * The repository whose descriptor is named `name`, or null when * no loaded repository carries that name. A discovered repository * that cannot be read surfaces its error (so callers can 500 * rather than 404) unless another repository matches cleanly * first. */ public RepositoryEntry? find(string name) throws Error { ensure_loaded(); Error? first_failure = null; foreach (var entry in discovered) { string entry_name; try { entry_name = entry.repo().name; } catch (Error e) { if (first_failure == null) { first_failure = e; } continue; } if (entry_name == name) { return entry; } } if (first_failure != null) { throw first_failure; } return null; } /** * Discovers repository directories: the configured subdirectories * when {@link UsmWebConfig.repositories} names them, otherwise every * subdirectory holding a `.usmr` or a `.dcbar`, falling back to the * legacy single-repository layout (`.usmr` only). Already-known * directories keep their entry (and loaded snapshot). */ private Vector discover() throws Error { var result = new Vector(); var dir_names = new Vector(); if (config.repositories.length > 0) { foreach (var name in config.repositories) { dir_names.add(name); } } else { try { var dir = Dir.open(config.repo_dir); string? child; while ((child = dir.read_name()) != null) { dir_names.add((!)child); } } catch (Error e) { throw new UsmWebError.INVALID_CONFIGURATION( @"Cannot read repository parent \"$(config.repo_dir)\": $(e.message)"); } } foreach (var name in dir_names.order_by(n => n, Operators.comparison())) { var child_path = Path.build_filename(config.repo_dir, name); if (!FileUtils.test(child_path, FileTest.IS_DIR)) { if (config.repositories.length > 0) { throw new UsmWebError.INVALID_CONFIGURATION( @"\"$name\" from the configured repositories list is not a directory under \"$(config.repo_dir)\""); } continue; } var descriptor = find_descriptor(child_path); if (descriptor == null) { if (config.repositories.length > 0) { throw new UsmWebError.INVALID_CONFIGURATION( @"\"$name\" from the configured repositories list has no *.usmr or *.dcbar under \"$(config.repo_dir)\""); } continue; } result.add(entry_for(child_path, (!)descriptor)); } if (result.length == 0) { var legacy_usmr = find_usmr(config.repo_dir); if (legacy_usmr != null) { if (legacy_dir != config.repo_dir) { legacy_dir = config.repo_dir; warning("[usm-web] \"$(config.repo_dir)\" holds a .usmr directly — serving it as a single repository (move it into a subdirectory for the multi-repository layout)\n"); } result.add(entry_for(config.repo_dir, (!)legacy_usmr)); } } drop_stale(result); return result; } /** * Reuses the entry for `dir_path` when discovery finds it again. * A directory whose descriptor kind changed on disk (a `.dcbar` * replaced by a `.usmr` between discoveries) gets a fresh entry * so no stale snapshot of the old kind lingers. */ private RepositoryEntry entry_for(string dir_path, string descriptor_path) { RepositoryEntry? existing = null; entries_by_dir.try_get(dir_path, out existing); if (existing != null && ((!)existing).kind == kind_of((!)descriptor_path)) { return (!)existing; } var created = kind_of((!)descriptor_path) == RepositoryKind.DCBA ? new RepositoryEntry.dcba(dir_path, descriptor_path) : new RepositoryEntry.usm(dir_path, descriptor_path); entries_by_dir.set(dir_path, created); return created; } /** Forgets entries whose directory is no longer discovered. */ private void drop_stale(Vector current) { var current_dirs = new Dictionary(); foreach (var entry in current) { current_dirs.set(entry.dir_path, entry); } foreach (var dir_path in entries_by_dir.keys.to_array()) { if (!current_dirs.has(dir_path)) { entries_by_dir.remove(dir_path); } } } /** * The first `*.usmr` or `*.dcbar` directly inside `dir`, or null * when there is none. A directory holding both is a * configuration error. */ private static string? find_descriptor(string dir) throws Error { string? usmr = find_suffix(dir, ".usmr"); string? dcbar = find_suffix(dir, ".dcbar"); if (usmr != null && dcbar != null) { throw new UsmWebError.INVALID_CONFIGURATION( @"\"$dir\" holds both a .usmr and a .dcbar — a repository directory must be either a USM source repository or a DCBA artifact repository, not both"); } return usmr ?? dcbar; } /** The first `*.usmr` directly inside `dir`, or null when there is none. */ private static string? find_usmr(string dir) { return find_suffix(dir, ".usmr"); } /** The first file ending in `suffix` directly inside `dir`, or null. */ private static string? find_suffix(string dir, string suffix) { try { var handle = Dir.open(dir); string? name = null; string? found = null; while ((name = handle.read_name()) != null) { if (name.has_suffix(suffix)) { found = name; break; } } return found == null ? null : Path.build_filename(dir, (!)found); } catch (Error e) { return null; } } /** The kind implied by a descriptor path's extension. */ private static RepositoryKind kind_of(string descriptor_path) { return descriptor_path.has_suffix(".dcbar") ? RepositoryKind.DCBA : RepositoryKind.USM; } } }