diff --git a/docs/dmf-get-tool.md b/docs/dmf-get-tool.md index 9670be76..8c9042d7 100644 --- a/docs/dmf-get-tool.md +++ b/docs/dmf-get-tool.md @@ -100,6 +100,7 @@ Two variables are automatically substituted in URLs: - `DMOD_DMFC_DIR` - Default output directory for DMFC files - `DMOD_MANIFEST` - Default manifest path or URL - `DMOD_LICENSE_DIR` - Default base directory for license resources (overridden by `--license-dir`) +- `DMOD_PACKAGE_DIR` - Default root for package resources and metadata (overridden by `--package-dir`) - `DMOD_VIEWS_DIR` - Default views directory (same as `--views-dir`) ### Version Matching @@ -304,6 +305,24 @@ dmf-get [options] [install] [@version] - `-o, --output-dir ` - Output directory for downloaded modules (default: `./dmf` or `DMOD_DMF_DIR`) +#### Package Directory + +- `--package-dir ` - Use a separate root for installed package resources, unpacked package directories and `.dmd`/`.dmr` metadata. Defaults to the non-empty `DMOD_PACKAGE_DIR` environment variable; the command-line option takes precedence. Without either setting, the existing combined layout is preserved. +- `.dmf` and `.dmfc` binaries stay directly in `--output-dir` / `DMOD_DMF_DIR`. Binary resources from `.dmr` are installed there by source filename. Non-binary `.dmr` resources resolve `${destination}` against the package root. Packages without `.dmr` keep their complete structure under `/`. +- Dependencies use the same package root. Configuration copying (`--config`, `--config-dir`, `--config-map`) reads resources from that root, including custom paths declared in the retained `.dmr` file. +- Default views move to `/views/`. Explicit `--views-dir` / `DMOD_VIEWS_DIR` and `--license-dir` / `DMOD_LICENSE_DIR` retain their precedence. Other explicit destinations declared by `.dmr` remain in effect. +- Relative paths are resolved from the working directory; missing parent directories are created. ZIP downloads are staged in the package root and removed after extraction; `.dmp` archives are stored there. Temporary ZIP extraction and the download cache keep their existing locations. +- `--mini` keeps its existing resource filtering, while `.dmd`/`.dmr` metadata stays in the package root. The dedicated `headers`, `docs` and `lib` commands keep their existing output paths. +- Use a package root outside the module output directory to keep the latter limited to `.dmf`/`.dmfc` files. Existing files from earlier installations are not moved or deleted. + +```bash +dmf-get -o ./dmf --package-dir ./packages mymodule +DMOD_PACKAGE_DIR=./packages dmf-get -o ./dmf -d dependencies.dmd + +# Separate module binaries, package resources and licenses +dmf-get -o ./dmf --package-dir ./packages --license-dir ./licenses -y mymodule +``` + #### License Options - `--license-dir ` - Install resources whose `.dmr` key is `license` under `//`, overriding their declared destination. Paths may be absolute or relative to the current working directory. Each module (including dependencies) gets its own directory; source filenames are preserved. Directory resources are copied recursively, and reinstalling updates their contents without adding another directory level. @@ -320,7 +339,7 @@ dmf-get --mini --license-dir ./licenses -y -d dependencies.dmd #### Views Options -- `--views-dir ` - Directory where views (`.dmv`) of the installed applications are placed (default: `DMOD_VIEWS_DIR`, otherwise `views` inside the output directory, e.g. `$DMOD_DMF_DIR/views`). Every module gets its own subdirectory `/`, which is what `${views_dir}` resolves to in `.dmr` files (e.g. `views=./views => ${views_dir}`), so views of different modules never overwrite each other. The contents of the `views` directory are copied into it (a reinstall overwrites them). The `views` resource is installed also with `--mini`. +- `--views-dir ` - Directory where views (`.dmv`) of the installed applications are placed (default: `DMOD_VIEWS_DIR`, otherwise `views` inside the package root when configured, or the output directory, e.g. `$DMOD_DMF_DIR/views`). Every module gets its own subdirectory `/`, which is what `${views_dir}` resolves to in `.dmr` files (e.g. `views=./views => ${views_dir}`), so views of different modules never overwrite each other. The contents of the `views` directory are copied into it (a reinstall overwrites them). The `views` resource is installed also with `--mini`. #### Configuration Options @@ -351,6 +370,7 @@ dmf-get --mini --license-dir ./licenses -y -d dependencies.dmd - `DMOD_DMFC_DIR` - Default DMFC output directory - `DMOD_MANIFEST` - Default manifest path or URL - `DMOD_LICENSE_DIR` - Default base directory for license resources (overridden by `--license-dir`) +- `DMOD_PACKAGE_DIR` - Default root for package resources and metadata (overridden by `--package-dir`) ### Examples diff --git a/tests/integration/test_dmf_get.sh b/tests/integration/test_dmf_get.sh index 12bcd052..e2c7c943 100755 --- a/tests/integration/test_dmf_get.sh +++ b/tests/integration/test_dmf_get.sh @@ -490,6 +490,7 @@ rm -rf test_type_both test_type_dmf_only test_type_nodmr typemod.zip dmfonly.zip echo "" bash "$SCRIPT_DIR/test_dmf_get_licenses.sh" "$BUILD_DIR" +bash "$SCRIPT_DIR/test_dmf_get_packages.sh" "$BUILD_DIR" echo "=== All dmf-get integration tests passed! ===" cd .. diff --git a/tests/integration/test_dmf_get_packages.sh b/tests/integration/test_dmf_get_packages.sh new file mode 100644 index 00000000..25230940 --- /dev/null +++ b/tests/integration/test_dmf_get_packages.sh @@ -0,0 +1,183 @@ +#!/bin/bash +# Verify separate package roots using local archives and actual installed files. +set -euo pipefail + +BUILD_DIR="$(cd "${1:-../build}" && pwd)" +DMF_GET="$BUILD_DIR/bin/tools/dmf-get" +TEST_DIR="$(mktemp -d)" +trap 'rm -rf "$TEST_DIR"' EXIT +cd "$TEST_DIR" +export DMOD_CACHE_DIR="$TEST_DIR/cache" +unset DMOD_PACKAGE_DIR DMOD_LICENSE_DIR DMOD_VIEWS_DIR + +fail() { + echo "FAIL: $*" + cat install.log 2>/dev/null || true + exit 1 +} + +mkdir -p parent/docs parent/settings parent/views parent/tools child/configs +echo 'parent dmf' > parent/parent.dmf +echo 'parent dmfc' > parent/parent.dmfc +echo 'helper dmfc' > parent/tools/helper.dmfc +echo 'parent docs' > parent/docs/README +echo 'parent config' > parent/settings/board.ini +echo 'parent view' > parent/views/main.dmv +echo 'parent license' > parent/LICENSE +echo child > parent/parent.dmd +cat > parent/parent.dmr <<'DMR' +dmf=./parent.dmf => ${destination}/${module}.dmf +dmfc=./parent.dmfc => ${destination}/${module}.dmfc +dmfc=./tools/helper.dmfc => ${destination}/${module}/tools/helper.dmfc +docs=./docs => ${destination}/${module}/docs +config=./settings => ${destination}/custom-config/${module} +views=./views => ${views_dir} +license=./LICENSE => ${destination}/${module}/LICENSE +dmd=./parent.dmd => ${destination}/${module}.dmd +DMR +# No .dmr: the complete package must move, and config lookup must still work. +echo 'child dmf' > child/child.dmf +echo 'child config' > child/configs/board.ini +(cd parent && zip -qr ../parent.zip .) +(cd child && zip -qr ../child.zip .) +echo 'raw module' > raw.dmf +echo 'packed modules' > bundle.dmp +cat > manifest.dmm < install.log 2>&1 || fail "install $*" +} +check_binary_dir() { + local unexpected + unexpected="$(find "$1" -mindepth 1 ! \( -type f \( -name '*.dmf' -o -name '*.dmfc' \) \))" + test -z "$unexpected" || fail "non-module entries in $1: $unexpected" +} +check_packages() { + local packages="$1" binaries="$2" + cmp parent/parent.dmfc "$binaries/parent.dmfc" + cmp parent/tools/helper.dmfc "$binaries/helper.dmfc" + cmp child/child.dmf "$binaries/child.dmf" + cmp parent/docs/README "$packages/parent/docs/README" + cmp parent/settings/board.ini "$packages/custom-config/parent/board.ini" + cmp parent/views/main.dmv "$packages/views/parent/main.dmv" + cmp parent/LICENSE "$packages/parent/LICENSE" + cmp parent/parent.dmd "$packages/parent.dmd" + cmp parent/parent.dmr "$packages/parent.dmr" + cmp child/configs/board.ini "$packages/child/configs/board.ini" + test ! -e "$packages/parent.zip" + test ! -e "$packages/child.zip" + check_binary_dir "$binaries" +} + +"$DMF_GET" --help > help.txt +grep -q -- '--package-dir' help.txt +grep -q 'DMOD_PACKAGE_DIR' help.txt + +# Backward compatibility: without the setting metadata/resources share -o. +run_install -o legacy parent +test -f legacy/parent.dmfc +test -f legacy/parent.dmd +test -f legacy/parent/docs/README +test -f legacy/child/configs/board.ini +test -f legacy/views/parent/main.dmv + +# Nested relative path with spaces/trailing slash, recursive deps, custom config mapping. +run_install -o binaries --package-dir 'nested/unpacked packages/' --config board.ini --config-dir installed-config parent +check_packages 'nested/unpacked packages' binaries +cmp parent/settings/board.ini installed-config/parent/board.ini + +# Explicit uncompressed preference keeps the binary root flat as well. +run_install -o dmf-binaries --package-dir dmf-packages --type dmf --no-dependencies parent +cmp parent/parent.dmf dmf-binaries/parent.dmf +test ! -e dmf-binaries/parent.dmfc +test ! -e dmf-binaries/helper.dmfc +check_binary_dir dmf-binaries + +# Reinstall into the same roots; directory resources must not gain another level. +run_install -o binaries --package-dir 'nested/unpacked packages' parent +check_packages 'nested/unpacked packages' binaries +test ! -e 'nested/unpacked packages/parent/docs/docs' + +# .dmd installs, tagged configs, no-.dmr fallback, and an absolute root whose +# final component equals a module name (must not be stripped by normalization). +printf 'parent driver=board.ini\nchild service=board.ini\n' > dependencies.dmd +run_install -o dmd-binaries --package-dir "$TEST_DIR/separate/parent" -d dependencies.dmd \ + --config-map 'driver:drivers;service:services' +check_packages "$TEST_DIR/separate/parent" dmd-binaries +cmp parent/settings/board.ini drivers/parent/board.ini +cmp child/configs/board.ini services/child/board.ini + +export DMOD_PACKAGE_DIR="$TEST_DIR/from env" +run_install -o env-binaries parent +check_packages "$DMOD_PACKAGE_DIR" env-binaries +export DMOD_PACKAGE_DIR="$TEST_DIR/unused env" +run_install -o cli-binaries --package-dir cli-packages parent +check_packages cli-packages cli-binaries +test ! -e "$DMOD_PACKAGE_DIR" +export DMOD_PACKAGE_DIR='' +run_install -o empty-env --no-dependencies child +test -f empty-env/child/configs/board.ini +unset DMOD_PACKAGE_DIR + +# Mini mode and explicit view/license destinations keep their existing priorities. +run_install -o mini-binaries --package-dir mini-packages --mini --no-dependencies parent +check_binary_dir mini-binaries +cmp parent/views/main.dmv mini-packages/views/parent/main.dmv +test -f mini-packages/parent.dmd +test -f mini-packages/parent.dmr +test ! -e mini-packages/parent/docs +test ! -e mini-packages/parent/LICENSE +run_install -o override-binaries --package-dir override-packages --mini --no-dependencies \ + --views-dir custom-views --license-dir custom-licenses parent +check_binary_dir override-binaries +cmp parent/views/main.dmv custom-views/parent/main.dmv +cmp parent/LICENSE custom-licenses/parent/LICENSE +test ! -e override-packages/views + +# Raw binaries stay in the module output; DMP archives go to the package root. +DMOD_DMF_DIR=raw-binaries run_install --package-dir raw-packages --no-dependencies raw +cmp raw.dmf raw-binaries/raw.dmf +check_binary_dir raw-binaries +run_install -o bundle-binaries --package-dir bundle-packages --no-dependencies bundle +cmp bundle.dmp bundle-packages/bundle.dmp +check_binary_dir bundle-binaries + +# Resource-only commands keep the meaning of their own -o. +run_install docs parent -o docs-only --package-dir unused-packages +cmp parent/docs/README docs-only/parent/docs/README +test ! -e unused-packages + +if "$DMF_GET" --package-dir > install.log 2>&1; then + fail 'missing argument should fail' +fi +grep -q -- '--package-dir requires an argument' install.log +for path in '' '--mini' 'unsafe;path' "$(printf '%0512d' 0)"; do + if "$DMF_GET" "${args[@]}" -o invalid --package-dir "$path" child > install.log 2>&1; then + fail "invalid package path should fail: $path" + fi +done + +echo 'not a directory' > blocked +if "$DMF_GET" "${args[@]}" -o blocked-binaries --package-dir blocked child > install.log 2>&1; then + fail 'unwritable package root should fail' +fi +check_binary_dir blocked-binaries + +# Existing blocking paths must fail metadata copies and no-.dmr extraction. +mkdir -p metadata-blocked/parent.dmd +if "$DMF_GET" "${args[@]}" -o metadata-binaries --package-dir metadata-blocked --no-dependencies parent > install.log 2>&1; then + fail 'metadata destination directory should not be treated as a file' +fi +mkdir -p contents-blocked +echo 'not a directory' > contents-blocked/child +if "$DMF_GET" "${args[@]}" -o contents-binaries --package-dir contents-blocked --no-dependencies child > install.log 2>&1; then + fail 'unwritable unpacked package directory should fail' +fi +check_binary_dir contents-binaries + +echo 'All dmf-get package directory tests passed!' diff --git a/tools/system/dmf-get/README.md b/tools/system/dmf-get/README.md index d6bec22a..4b37b4ad 100644 --- a/tools/system/dmf-get/README.md +++ b/tools/system/dmf-get/README.md @@ -182,6 +182,7 @@ This copies `mymodule`'s configuration to `./config/drivers/mymodule/stm32f746g- - `-d, --dependencies ` - Path or URL to dependencies (.dmd) file - `-m, --manifest ` - Path or URL to manifest file - `-o, --output-dir ` - Output directory for downloaded modules +- `--package-dir ` - Separate root for package resources, unpacked directories and `.dmd`/`.dmr` metadata (default: `DMOD_PACKAGE_DIR`, otherwise the module output directory). - `--license-dir ` - Install `.dmr` license resources to `//`, including dependencies. Defaults to `DMOD_LICENSE_DIR`; otherwise uses the package destination. Also collects licenses with `--mini`. - `--config ` - Configuration file to copy from module (for single module only) - `--config-dir ` - Directory where configuration files should be copied @@ -198,6 +199,31 @@ This copies `mymodule`'s configuration to `./config/drivers/mymodule/stm32f746g- - `-h, --help` - Show help message - `-v, --version` - Show version information +### Separate Package Directory + +```bash +dmf-get -o ./dmf --package-dir ./packages mymodule +DMOD_PACKAGE_DIR=./packages dmf-get -o ./dmf -d dependencies.dmd +``` + +With a separate package root, `./dmf` receives only `.dmf`/`.dmfc` binaries; +package resources and metadata go under `./packages`. `.dmr` resources use that +root for `${destination}`, while module binaries are installed directly in `-o` +by source filename. Packages without `.dmr` retain their structure under +`./packages/`. Dependencies and configuration copying use the new root. + +The CLI option overrides a non-empty `DMOD_PACKAGE_DIR`. When neither is set, +the existing behavior is preserved. Relative paths use the working directory, and missing parent +directories are created. Existing installations are not moved or cleaned up. +Choose a package directory outside `-o` to keep the module directory separate. + +Default views go to `/views/`. Explicit view and license +directories still override their destinations, and other explicit `.dmr` paths +remain effective. Mini mode keeps its resource filtering. ZIP downloads are +staged in the package root and removed after extraction; `.dmp` archives are +stored there. Temporary extraction and caching are unchanged. The dedicated +`headers`, `docs` and `lib` commands retain their existing `-o` behavior. + ## Caching `dmf-get` caches downloaded manifests and package files to reduce network requests and speed up subsequent downloads. The cache is stored in `~/.cache/dmod/dmf-get/` by default. diff --git a/tools/system/dmf-get/main.c b/tools/system/dmf-get/main.c index e0f3dc30..6b77d0ed 100644 --- a/tools/system/dmf-get/main.c +++ b/tools/system/dmf-get/main.c @@ -40,6 +40,7 @@ #define ENV_LIB_DIR "DMOD_LIB_DIR" #define ENV_VIEWS_DIR "DMOD_VIEWS_DIR" #define ENV_LICENSE_DIR "DMOD_LICENSE_DIR" +#define ENV_PACKAGE_DIR "DMOD_PACKAGE_DIR" #define ENV_CACHE_DIR "DMOD_CACHE_DIR" // Cache directories @@ -1164,6 +1165,10 @@ static const char* g_views_dir = NULL; // When unset, preserve the destinations declared by the package's .dmr file. static const char* g_license_dir = NULL; +// Optional installation root for package resources and metadata. Module binaries +// still go to output_dir; unset preserves the existing combined layout. +static const char* g_package_dir = NULL; + /** * Create a resource context for installing a module and hand it the views * directory, so that .dmr entries using ${views_dir} resolve. @@ -1242,6 +1247,20 @@ static void NormalizeResourceOutputDir(char* safe_output_dir, size_t safe_output } } +/** + * Resolve the resource root for a normal module installation. An explicit + * package directory is used verbatim, even when its last component is the + * module name. The dedicated headers/docs/lib commands keep their own -o. + */ +static void GetInstallResourceOutputDir(char* path, size_t path_size, + const char* output_dir, const char* module_name) { + if (g_package_dir) { + Dmod_SnPrintf(path, path_size, "%s", g_package_dir); + } else { + NormalizeResourceOutputDir(path, path_size, output_dir, module_name); + } +} + /** * @brief Extract specific resource (headers, docs, or lib) from ZIP file * @@ -1475,7 +1494,7 @@ static bool CopyConfigurationFile(const char* module_name, const char* config_pa DMOD_LOG_INFO("Looking for configuration file: %s for module: %s\n", substituted_config_path, module_name); char safe_output_dir[DMOD_MAX_PATH_LEN]; - NormalizeResourceOutputDir(safe_output_dir, sizeof(safe_output_dir), output_dir, module_name); + GetInstallResourceOutputDir(safe_output_dir, sizeof(safe_output_dir), output_dir, module_name); // First, check if the module has a .dmr file in the output directory char dmr_path[DMOD_MAX_PATH_LEN]; @@ -1720,7 +1739,7 @@ static bool CheckLicenseAcceptance(const char* extract_dir, const char* module_n // Parse .dmr file to find license resource char license_source[1024] = ""; char safe_output_dir[1024]; - NormalizeResourceOutputDir(safe_output_dir, sizeof(safe_output_dir), output_dir, module_name); + GetInstallResourceOutputDir(safe_output_dir, sizeof(safe_output_dir), output_dir, module_name); Dmod_ResourceContext_t* res_ctx = InitInstallResourceContext(safe_output_dir, module_name); if (res_ctx) { if (Dmod_Resource_ParseFile(res_ctx, dmr_file)) { @@ -2006,49 +2025,63 @@ static bool ExtractZipAndFindModule(const char* zip_path, const char* output_dir strncpy(output_file, dest_path, output_file_size - 1); output_file[output_file_size - 1] = '\0'; - // Copy the .dmd file if found and if caller wants it - if (dmd_file[0] != '\0' && output_dmd_file != NULL && output_dmd_file_size > 0) { - const char* dmd_filename = strrchr(dmd_file, '/'); - if (dmd_filename) { - dmd_filename++; // Skip the '/' - } else { - dmd_filename = dmd_file; + bool resource_install_failed = false; + + // Retain dependency metadata outside the module directory when requested. + // Also retain .dmr so later configuration copying can resolve custom mappings. + const char* metadata_sources[] = {dmd_file, g_package_dir ? dmr_file : ""}; + const char* metadata_dir = g_package_dir ? g_package_dir : output_dir; + for (size_t i = 0; i < 2; i++) { + const char* source = metadata_sources[i]; + if (source[0] == '\0' || + (i == 0 && (output_dmd_file == NULL || output_dmd_file_size == 0))) { + continue; } - - char dmd_dest_path[512]; - Dmod_SnPrintf(dmd_dest_path, sizeof(dmd_dest_path), "%s/%s", output_dir, dmd_filename); - - char cp_dmd_cmd[1024]; - Dmod_SnPrintf(cp_dmd_cmd, sizeof(cp_dmd_cmd), "cp \"%s\" \"%s\"", dmd_file, dmd_dest_path); - result = system(cp_dmd_cmd); - - if (result == 0) { - strncpy(output_dmd_file, dmd_dest_path, output_dmd_file_size - 1); - output_dmd_file[output_dmd_file_size - 1] = '\0'; + const char* metadata_name = strrchr(source, '/'); + metadata_name = metadata_name ? metadata_name + 1 : source; + char metadata_path[512]; + int path_len = Dmod_SnPrintf(metadata_path, sizeof(metadata_path), "%s/%s", + metadata_dir, metadata_name); + if (path_len < 0 || (size_t)path_len >= sizeof(metadata_path)) { + DMOD_LOG_ERROR("Metadata destination is too long\n"); + resource_install_failed = true; + continue; + } + struct stat metadata_stat; + if (g_package_dir && stat(metadata_path, &metadata_stat) == 0 && !S_ISREG(metadata_stat.st_mode)) { + DMOD_LOG_ERROR("Metadata destination is not a regular file: %s\n", metadata_path); + resource_install_failed = true; + continue; + } + char cp_metadata_cmd[2048]; + Dmod_SnPrintf(cp_metadata_cmd, sizeof(cp_metadata_cmd), "cp \"%s\" \"%s\"", + source, metadata_path); + if (system(cp_metadata_cmd) != 0) { + DMOD_LOG_ERROR("Failed to copy metadata to: %s\n", metadata_path); + if (g_package_dir) resource_install_failed = true; + } else if (i == 0) { + Dmod_SnPrintf(output_dmd_file, output_dmd_file_size, "%s", metadata_path); DMOD_LOG_INFO("Dependencies file copied to: %s\n", output_dmd_file); } } - - bool license_install_failed = false; // Process .dmr file if found if (dmr_file[0] != '\0') { DMOD_LOG_INFO("Processing resource file: %s\n", dmr_file); - // Initialize resource parser. output_dir here is normally already - // the bare install root (top-level .dmf/.dmfc/.dmd above are copied - // straight into it) - normalize defensively in case a caller passed - // an already module-scoped -o. + // Resolve non-module resources relative to the optional package root. char safe_output_dir[1024]; - NormalizeResourceOutputDir(safe_output_dir, sizeof(safe_output_dir), output_dir, module_name); + GetInstallResourceOutputDir(safe_output_dir, sizeof(safe_output_dir), output_dir, module_name); Dmod_ResourceContext_t* res_ctx = InitInstallResourceContext(safe_output_dir, module_name); if (!res_ctx) { DMOD_LOG_ERROR("Failed to initialize resource parser\n"); + if (g_package_dir) resource_install_failed = true; } else { // Parse the .dmr file if (!Dmod_Resource_ParseFile(res_ctx, dmr_file)) { DMOD_LOG_ERROR("Failed to parse resource file: %s\n", Dmod_Resource_GetError(res_ctx)); + if (g_package_dir) resource_install_failed = true; Dmod_Resource_Free(res_ctx); } else { size_t res_count = Dmod_Resource_GetEntryCount(res_ctx); @@ -2059,6 +2092,7 @@ static bool ExtractZipAndFindModule(const char* zip_path, const char* output_dir Dmod_ResourceEntry_t res_entry; if (!Dmod_Resource_GetEntry(res_ctx, i, &res_entry)) { DMOD_LOG_ERROR("Failed to get resource entry %zu\n", i); + if (g_package_dir) resource_install_failed = true; continue; } @@ -2079,6 +2113,24 @@ static bool ExtractZipAndFindModule(const char* zip_path, const char* output_dir continue; } + if (g_package_dir && res_entry.is_dmf_dmfc) { + // Keep binary resources flat in the module output directory, + // regardless of their package-relative .dmr destination. + const char* binary_name = strrchr(res_entry.source, '/'); + binary_name = binary_name ? binary_name + 1 : res_entry.source; + int path_len = Dmod_SnPrintf(res_entry.destination, sizeof(res_entry.destination), + "%s/%s", output_dir, binary_name); + size_t name_len = strlen(binary_name); + const char* suffix = strcmp(res_entry.key, "dmfc") == 0 ? ".dmfc" : ".dmf"; + size_t suffix_len = strlen(suffix); + if (path_len < 0 || (size_t)path_len >= sizeof(res_entry.destination) || + name_len <= suffix_len || strcmp(binary_name + name_len - suffix_len, suffix) != 0) { + DMOD_LOG_ERROR("Invalid module resource destination\n"); + resource_install_failed = true; + continue; + } + } + if (redirect_license) { // Keep each module's licenses separate and preserve source names. // Strip trailing slashes so directory resources also have a basename. @@ -2096,7 +2148,7 @@ static bool ExtractZipAndFindModule(const char* zip_path, const char* output_dir strcmp(basename, "..") == 0 || path_len < 0 || (size_t)path_len >= sizeof(res_entry.destination)) { DMOD_LOG_ERROR(" Invalid or too long license destination\n"); - license_install_failed = true; + resource_install_failed = true; continue; } } @@ -2112,7 +2164,7 @@ static bool ExtractZipAndFindModule(const char* zip_path, const char* output_dir // Validate paths for safety if (!IsPathSafe(full_source) || !IsPathSafe(res_entry.destination)) { DMOD_LOG_ERROR(" Invalid path detected (contains unsafe characters)\n"); - if (redirect_license) license_install_failed = true; + if (redirect_license || g_package_dir) resource_install_failed = true; continue; } @@ -2120,10 +2172,16 @@ static bool ExtractZipAndFindModule(const char* zip_path, const char* output_dir struct stat st; if (stat(full_source, &st) != 0) { DMOD_LOG_WARN(" Resource source not found: %s\n", full_source); - if (redirect_license) license_install_failed = true; + if (redirect_license || g_package_dir) resource_install_failed = true; continue; } + if (g_package_dir && res_entry.is_dmf_dmfc && !S_ISREG(st.st_mode)) { + DMOD_LOG_ERROR("Module resource must be a regular file: %s\n", full_source); + resource_install_failed = true; + continue; + } + // Create destination directory if needed char dest_parent[1024]; strncpy(dest_parent, res_entry.destination, sizeof(dest_parent) - 1); @@ -2136,7 +2194,7 @@ static bool ExtractZipAndFindModule(const char* zip_path, const char* output_dir // Validate parent path if (!IsPathSafe(dest_parent)) { DMOD_LOG_ERROR(" Invalid destination path (contains unsafe characters)\n"); - if (redirect_license) license_install_failed = true; + if (redirect_license || g_package_dir) resource_install_failed = true; continue; } @@ -2145,14 +2203,14 @@ static bool ExtractZipAndFindModule(const char* zip_path, const char* output_dir int mkdir_result = system(mkdir_cmd); if (mkdir_result != 0) { DMOD_LOG_ERROR(" Failed to create destination directory: %s\n", dest_parent); - if (redirect_license) license_install_failed = true; + if (redirect_license || g_package_dir) resource_install_failed = true; continue; } } // Copy the resource char cp_res_cmd[2048]; - if (S_ISDIR(st.st_mode) && (is_views || redirect_license)) { + if (S_ISDIR(st.st_mode) && (is_views || redirect_license || g_package_dir)) { // Copy directory contents so repeated installs do not nest them. Dmod_SnPrintf(cp_res_cmd, sizeof(cp_res_cmd), "mkdir -p \"%s\" && cp -r \"%s/.\" \"%s/\"", @@ -2170,7 +2228,7 @@ static bool ExtractZipAndFindModule(const char* zip_path, const char* output_dir DMOD_LOG_INFO(" Resource installed successfully\n"); } else { DMOD_LOG_ERROR(" Failed to install resource\n"); - if (redirect_license) license_install_failed = true; + if (redirect_license || g_package_dir) resource_install_failed = true; } } @@ -2178,15 +2236,16 @@ static bool ExtractZipAndFindModule(const char* zip_path, const char* output_dir } } } else { - // No .dmr file found - copy entire extracted directory to output_dir/module_name/ + // No .dmr file found - copy the package under the selected resource root. // This ensures config files and other resources are available DMOD_LOG_INFO("No .dmr file found, copying entire package structure\n"); char module_dir[1024]; - Dmod_SnPrintf(module_dir, sizeof(module_dir), "%s/%s", output_dir, module_name); + int module_dir_len = Dmod_SnPrintf(module_dir, sizeof(module_dir), "%s/%s", + g_package_dir ? g_package_dir : output_dir, module_name); // Validate path for safety - if (IsPathSafe(module_dir)) { + if (module_dir_len >= 0 && (size_t)module_dir_len < sizeof(module_dir) && IsPathSafe(module_dir)) { // Create module directory char mkdir_cmd[2048]; Dmod_SnPrintf(mkdir_cmd, sizeof(mkdir_cmd), "mkdir -p \"%s\"", module_dir); @@ -2204,10 +2263,15 @@ static bool ExtractZipAndFindModule(const char* zip_path, const char* output_dir DMOD_LOG_INFO("Package contents copied to: %s\n", module_dir); } else { DMOD_LOG_WARN("Failed to copy package contents to module directory\n"); + if (g_package_dir) resource_install_failed = true; } } else { DMOD_LOG_WARN("Failed to create module directory: %s\n", module_dir); + if (g_package_dir) resource_install_failed = true; } + } else if (g_package_dir) { + DMOD_LOG_ERROR("Invalid package directory\n"); + resource_install_failed = true; } } @@ -2220,7 +2284,7 @@ static bool ExtractZipAndFindModule(const char* zip_path, const char* output_dir Dmod_SnPrintf(rm_temp_cmd, sizeof(rm_temp_cmd), "rm -rf \"%s\"", extract_dir); system(rm_temp_cmd); - if (license_install_failed) { + if (resource_install_failed) { return false; } DMOD_LOG_INFO("Module installed to: %s\n", output_file); @@ -2642,7 +2706,9 @@ static void PrintUsage(const char* app_name) { Dmod_Printf(" \"driver:./config/drivers;service:./config/services\"\n"); Dmod_Printf(" --views-dir Directory where views of the installed applications are placed,\n"); Dmod_Printf(" each module in its own / (= ${views_dir} in .dmr)\n"); - Dmod_Printf(" (default: $DMOD_VIEWS_DIR, otherwise /views)\n"); + Dmod_Printf(" (default: $DMOD_VIEWS_DIR, otherwise /views or /views)\n"); + Dmod_Printf(" --package-dir Root for unpacked package resources and .dmd/.dmr metadata\n"); + Dmod_Printf(" (default: $DMOD_PACKAGE_DIR, otherwise the module output directory)\n"); Dmod_Printf(" --license-dir Install .dmr license resources to //\n"); Dmod_Printf(" (default: $DMOD_LICENSE_DIR, otherwise the .dmr destination)\n"); Dmod_Printf(" Also installs licenses in --mini mode\n"); @@ -2672,6 +2738,7 @@ static void PrintUsage(const char* app_name) { Dmod_Printf(" %s Static library output directory\n", ENV_LIB_DIR); Dmod_Printf(" %s Views output directory (same as --views-dir)\n", ENV_VIEWS_DIR); Dmod_Printf(" %s License output directory (same as --license-dir)\n", ENV_LICENSE_DIR); + Dmod_Printf(" %s Package resource directory (same as --package-dir)\n", ENV_PACKAGE_DIR); Dmod_Printf(" %s Cache directory (default: ~/.cache/dmod/dmf-get)\n\n", ENV_CACHE_DIR); Dmod_Printf("Examples:\n"); Dmod_Printf(" %s mymodule # Download latest version\n", app_name); @@ -2958,22 +3025,32 @@ static int DownloadModule(const char* module_name, const char* module_version, const char* url = url_ptr; const char* ext = strrchr(url, '.'); char output_file[512]; - + const char* download_dir = output_dir; + if (g_package_dir && ext && (strcmp(ext, ".zip") == 0 || strcmp(ext, ".dmp") == 0)) { + download_dir = g_package_dir; + } + int output_path_len; + if (ext && (strcmp(ext, ".dmf") == 0 || strcmp(ext, ".dmfc") == 0 || strcmp(ext, ".zip") == 0 || strcmp(ext, ".dmp") == 0)) { - Dmod_SnPrintf(output_file, sizeof(output_file), "%s/%s%s%s%s", - output_dir, entry.name, + output_path_len = Dmod_SnPrintf(output_file, sizeof(output_file), "%s/%s%s%s%s", + download_dir, entry.name, version_to_use ? "-" : "", version_to_use ? version_to_use : "", ext); } else { // Default to .dmf if no extension - Dmod_SnPrintf(output_file, sizeof(output_file), "%s/%s%s%s.dmf", - output_dir, entry.name, + output_path_len = Dmod_SnPrintf(output_file, sizeof(output_file), "%s/%s%s%s.dmf", + download_dir, entry.name, version_to_use ? "-" : "", version_to_use ? version_to_use : ""); } + if (output_path_len < 0 || (size_t)output_path_len >= sizeof(output_file)) { + DMOD_LOG_ERROR("Download destination is too long\n"); + return FinalizeModuleDownload(module_name, module_version, 1); + } + // Check if file already exists bool already_exists = (Dmod_Access(output_file, DMOD_F_OK) == 0); if (already_exists) { @@ -3147,6 +3224,13 @@ int main(int argc, char* argv[]) { } g_views_dir = argv[i]; } + else if (strcmp(argv[i], "--package-dir") == 0) { + if (++i >= argc) { + DMOD_LOG_ERROR("Error: --package-dir requires an argument\n"); + return 1; + } + g_package_dir = argv[i]; + } else if (strcmp(argv[i], "--license-dir") == 0) { if (++i >= argc) { DMOD_LOG_ERROR("Error: --license-dir requires an argument\n"); @@ -3333,6 +3417,19 @@ int main(int argc, char* argv[]) { return 1; } + if (!g_package_dir) { + g_package_dir = Dmod_GetEnv(ENV_PACKAGE_DIR); + if (g_package_dir && g_package_dir[0] == '\0') { + g_package_dir = NULL; + } + } + if (g_package_dir && (g_package_dir[0] == '\0' || !IsPathSafe(g_package_dir) || + strlen(g_package_dir) >= DMOD_RESOURCE_MAX_PATH_LEN)) { + DMOD_LOG_ERROR("Error: --package-dir requires a non-empty, safe path shorter than %d characters\n", + DMOD_RESOURCE_MAX_PATH_LEN); + return 1; + } + if (!module_spec && !dependencies_path) { DMOD_LOG_ERROR("Error: No module name or dependencies file specified\n"); PrintUsage(argv[0]); @@ -3595,6 +3692,16 @@ int main(int argc, char* argv[]) { return 1; } + if (g_package_dir) { + char mkdir_cmd[DMOD_MAX_CMD_LEN]; + Dmod_SnPrintf(mkdir_cmd, sizeof(mkdir_cmd), "mkdir -p \"%s\"", g_package_dir); + if (system(mkdir_cmd) != 0) { + DMOD_LOG_ERROR("Failed to create package directory: %s\n", g_package_dir); + curl_global_cleanup(); + return 1; + } + } + // Get manifest path if (!manifest_path) { manifest_path = Dmod_GetEnv(ENV_MANIFEST);