Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 51 additions & 9 deletions aas-web-ui/src/composables/AAS/AASXImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useSMEFile } from '@/composables/AAS/SubmodelElements/File';
import { useAASRepositoryClient } from '@/composables/Client/AASRepositoryClient';
import { useCDRepositoryClient } from '@/composables/Client/CDRepositoryClient';
import { useSMRepositoryClient } from '@/composables/Client/SMRepositoryClient';
import { safeSegment } from '@/utils/StringUtils';
import { deserializeXml } from '../../../node_modules/basyx-typescript-sdk/dist/lib/aas-dataformat-xml/xmlization.js';

type JsonRecord = Record<string, unknown>;
Expand Down Expand Up @@ -79,7 +80,7 @@ function isExternalHttpUrl(path: string): boolean {
}
}

function normalizePackagePath(path: string): string {
export function normalizePackagePath(path: string): string {
if (!path) return '';

let normalized = path.trim();
Expand All @@ -95,7 +96,7 @@ function normalizePackagePath(path: string): string {
return normalized;
}

function packagePathCandidates(path: string): string[] {
export function packagePathCandidates(path: string): string[] {
const candidates = new Set<string>();
const normalized = normalizePackagePath(path);
if (normalized) candidates.add(normalized);
Expand Down Expand Up @@ -126,7 +127,7 @@ function buildSupplementaryMap(parts: Part[]): Map<string, Part> {
return map;
}

function pickSupplementaryPart(path: string, supplementaryMap: Map<string, Part>): Part | null {
export function pickSupplementaryPart(path: string, supplementaryMap: Map<string, Part>): Part | null {
for (const candidate of packagePathCandidates(path)) {
const part = supplementaryMap.get(candidate);
if (part) return part;
Expand All @@ -136,10 +137,40 @@ function pickSupplementaryPart(path: string, supplementaryMap: Map<string, Part>
const fileName = normalized.split('/').pop() || '';
if (fileName === '') return null;

const matchingParts = Array.from(supplementaryMap.values()).filter(
(part) => (part.URI.pathname.split('/').pop() || '') === fileName
const decodedFileName = (() => {
try {
return decodeURIComponent(fileName);
} catch {
return fileName;
}
})();

const sanitizedFileName = safeSegment(decodedFileName, '');
if (sanitizedFileName !== '') {
const directSanitizedMatch =
supplementaryMap.get(`/aasx-suppl/${sanitizedFileName}`) ||
supplementaryMap.get(`aasx-suppl/${sanitizedFileName}`);
if (directSanitizedMatch) return directSanitizedMatch;
}

const fileNameCandidates = new Set<string>([fileName, decodedFileName]);
if (sanitizedFileName !== '') fileNameCandidates.add(sanitizedFileName);

const uniqueParts = Array.from(
new Map(Array.from(supplementaryMap.values()).map((part) => [part.URI.pathname, part])).values()
);

const matchingParts = uniqueParts.filter((part) => {
const partFileName = part.URI.pathname.split('/').pop() || '';
if (fileNameCandidates.has(partFileName)) return true;

try {
return fileNameCandidates.has(decodeURIComponent(partFileName));
} catch {
return false;
}
});

return matchingParts.length === 1 ? matchingParts[0] : null;
}

Expand Down Expand Up @@ -195,13 +226,23 @@ function collectAttachmentUploads(

const visited = new WeakSet<object>();

const visit = (node: unknown, idShortPath: string[]): void => {
const visit = (node: unknown, idShortPath: string[], parentModelType: string = ''): void => {
if (!node || typeof node !== 'object') return;
if (visited.has(node as object)) return;
visited.add(node as object);

if (Array.isArray(node)) {
for (const item of node) visit(item, idShortPath);
for (const [index, item] of node.entries()) {
let indexedPath = idShortPath;

// AAS paths address SubmodelElementList children using bracket index notation (e.g., Markings[0]).
if (parentModelType === 'SubmodelElementList' && idShortPath.length > 0) {
indexedPath = [...idShortPath];
indexedPath[indexedPath.length - 1] = `${indexedPath[indexedPath.length - 1]}[${index}]`;
}

visit(item, indexedPath, parentModelType);
}
return;
}

Expand Down Expand Up @@ -246,8 +287,9 @@ function collectAttachmentUploads(
});
}

for (const value of Object.values(record)) {
visit(value, nextPath);
for (const [key, value] of Object.entries(record)) {
const nestedParentModelType = key === 'value' || key === 'statements' ? modelType : '';
visit(value, nextPath, nestedParentModelType);
}
};

Expand Down
41 changes: 31 additions & 10 deletions aas-web-ui/src/composables/AAS/AASXPackaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useSMRepositoryClient } from '@/composables/Client/SMRepositoryClient';
import { useRequestHandling } from '@/composables/RequestHandling';
import { extractId as extractIdFromReference } from '@/utils/AAS/ReferenceUtil';
import { base64Encode } from '@/utils/EncodeDecodeUtils';
import { safeSegment } from '@/utils/StringUtils';
import { serializeXml } from '../../../node_modules/basyx-typescript-sdk/dist/lib/aas-dataformat-xml/xmlization.js';
import { BaSyxEnvironment } from '../../../node_modules/basyx-typescript-sdk/dist/models/BaSyxEnvironment.js';

Expand Down Expand Up @@ -155,14 +156,6 @@ function normalizeId(id: string): string {
return id?.trim() || '';
}

function safeSegment(value: string, fallback: string): string {
const cleaned = value
?.trim()
.replace(/[^a-zA-Z0-9._-]/g, '-')
.replace(/-+/g, '-');
return cleaned && cleaned !== '' ? cleaned : fallback;
}

function isExternalHttpUrl(path: string): boolean {
if (!path || path.trim() === '') return false;

Expand All @@ -174,6 +167,29 @@ function isExternalHttpUrl(path: string): boolean {
}
}

function resolveHttpOrigin(url: string, baseUrl?: string): string | null {
if (!url || url.trim() === '') return null;

try {
const parsed = baseUrl ? new URL(url, baseUrl) : new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
return parsed.origin;
} catch {
return null;
}
}

export function isCrossOriginHttpUrl(url: string, referenceUrl: string): boolean {
const absoluteTargetOrigin = resolveHttpOrigin(url);
const targetOrigin = absoluteTargetOrigin || resolveHttpOrigin(url, referenceUrl);
if (!targetOrigin) return false;

const referenceOrigin = resolveHttpOrigin(referenceUrl);
if (!referenceOrigin) return absoluteTargetOrigin !== null;

return targetOrigin !== referenceOrigin;
}

function traverseObjects(root: unknown, visitor: (node: unknown) => void): void {
if (!root || typeof root !== 'object') return;

Expand Down Expand Up @@ -255,7 +271,7 @@ function collectFileBindings(raw: unknown, clean: unknown, bindings: FileBinding
}
}

function resolveAttachmentFilename(file: JsonRecord, index: number, contentType: string): string {
export function resolveAttachmentFilename(file: JsonRecord, index: number, contentType: string): string {
const value = asString(file.value);
const idShort = asString(file.idShort) || `file-${index + 1}`;

Expand Down Expand Up @@ -439,7 +455,12 @@ export function useAASXPackaging(): {
const thumbnailPath = asString(defaultThumbnail.path).trim();
if (thumbnailPath === '') return null;

if (isExternalHttpUrl(thumbnailPath)) {
const isExternalThumbnail =
defaultThumbnail.isExternal === true ||
(defaultThumbnail.isExternal === undefined && isExternalHttpUrl(thumbnailPath)) ||
isCrossOriginHttpUrl(thumbnailPath, aasPath);

if (isExternalThumbnail) {
warnings.push(`Skipped external thumbnail URL: ${thumbnailPath}`);
return null;
}
Expand Down
33 changes: 33 additions & 0 deletions aas-web-ui/src/utils/StringUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,36 @@ export function stripLastCharacter(string: string): string {
export function isEmptyString(val: string): boolean {
return !val || val.trim() === '';
}

/**
* Sanitizes a string to be safe for path/file segments by replacing unsupported characters.
* Rejects dot-only segments and reserved Windows device names.
*
* @param {string} value - The value to sanitize.
* @param {string} fallback - The value to return if sanitization results in an empty string.
* @returns {string} A sanitized segment or the fallback.
*/
export function safeSegment(value: string, fallback: string): string {
const cleaned = value
?.trim()
.replace(/[^a-zA-Z0-9._-]/g, '-')
.replace(/-+/g, '-');

if (!cleaned) {
return fallback;
}

// Strip leading and trailing dots to avoid dot-only or hidden path segments.
const dotStripped = cleaned.replace(/^\.+/, '').replace(/\.+$/, '');
if (!dotStripped || dotStripped === '.' || dotStripped === '..') {
return fallback;
}

// Reject reserved Windows device names.
const windowsReservedPattern = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
if (windowsReservedPattern.test(dotStripped)) {
return fallback;
}

return dotStripped;
}
82 changes: 82 additions & 0 deletions aas-web-ui/tests/composables/AAS/AASXImport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Part } from 'aasx-package-ts';
import { describe, expect, it } from 'vitest';
import {
buildAttachmentSmePath,
normalizePackagePath,
packagePathCandidates,
pickSupplementaryPart,
} from '@/composables/AAS/AASXImport';

type MinimalPart = Pick<Part, 'URI'>;

function partWithPath(path: string): MinimalPart {
return {
URI: new URL(`https://package.local${path}`),
};
}

describe('AASXImport.ts; pure helper tests', () => {
it('builds client import SME attachment path with dot-separated idShort segments', () => {
const smEndpoint = 'https://example.test/submodels/encoded-submodel-id';
const idShortPath = ['Documents', 'Manual', 'Pdf File'];

const path = buildAttachmentSmePath(smEndpoint, idShortPath);

expect(path).toBe(
'https://example.test/submodels/encoded-submodel-id/submodel-elements/Documents.Manual.Pdf%20File'
);
});

it('normalizes package path by trimming, removing query, and adding leading slash', () => {
const normalized = normalizePackagePath(' aasx-suppl/file.png?cache=1 ');

expect(normalized).toBe('/aasx-suppl/file.png');
});

it('creates decoded package path candidates for encoded path segments', () => {
const candidates = packagePathCandidates('/aasx-suppl/Markings%5B0%5D.png');

expect(candidates).toContain('/aasx-suppl/Markings%5B0%5D.png');
expect(candidates).toContain('/aasx-suppl/Markings[0].png');
});

it('matches supplementary parts whose filenames were sanitized during packaging', () => {
const supplementaryMap = new Map<string, Part>();
const packagedPart = partWithPath(
'/aasx-suppl/aHR0cHM6Ly9hZG1pbi1zaGVsbC5pby9pZHRhL1N1Ym1vZGVsVGVtcGxhdGUvRGlnaXRhbE5hbWVwbGF0ZS8zLzA-Markings-0-.MarkingFile-Schwindegg.png'
);
supplementaryMap.set(packagedPart.URI.pathname, packagedPart as Part);

const foundPart = pickSupplementaryPart(
'aHR0cHM6Ly9hZG1pbi1zaGVsbC5pby9pZHRhL1N1Ym1vZGVsVGVtcGxhdGUvRGlnaXRhbE5hbWVwbGF0ZS8zLzA-Markings[0].MarkingFile-Schwindegg.png',
supplementaryMap
);

expect(foundPart).toBe(packagedPart as Part);
});

it('does not report ambiguity when one underlying part is indexed by multiple path candidates', () => {
const supplementaryMap = new Map<string, Part>();
const packagedPart = partWithPath('/aasx-suppl/folder/shared.png');

// Simulate buildSupplementaryMap behavior where the same Part is stored under multiple candidate keys.
supplementaryMap.set('/aasx-suppl/folder/shared.png', packagedPart as Part);
supplementaryMap.set('aasx-suppl/folder/shared.png', packagedPart as Part);

const foundPart = pickSupplementaryPart('shared.png', supplementaryMap);

expect(foundPart).toBe(packagedPart as Part);
});

it('returns null when filename-only matching would be ambiguous', () => {
const supplementaryMap = new Map<string, Part>();
const firstPart = partWithPath('/aasx-suppl/folderA/shared.png');
const secondPart = partWithPath('/aasx-suppl/folderB/shared.png');
supplementaryMap.set(firstPart.URI.pathname, firstPart as Part);
supplementaryMap.set(secondPart.URI.pathname, secondPart as Part);

const foundPart = pickSupplementaryPart('shared.png', supplementaryMap);

expect(foundPart).toBeNull();
});
});
84 changes: 84 additions & 0 deletions aas-web-ui/tests/composables/AAS/AASXPackaging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import {
isCrossOriginHttpUrl,
resolveAttachmentFetchPath,
resolveAttachmentFilename,
} from '@/composables/AAS/AASXPackaging';

describe('AASXPackaging.ts; pure helper tests', () => {
it('preserves repository attachment fetch path for download packaging', () => {
const path = ' https://example.test/submodels/abc/submodel-elements/Documents.Manual.Pdf%20File ';

const fetchPath = resolveAttachmentFetchPath(path);

expect(fetchPath).toBe('https://example.test/submodels/abc/submodel-elements/Documents.Manual.Pdf%20File');
});

it('sanitizes filename derived from file.value to match package path rules', () => {
const file = {
value: 'aHR0cHM6Ly9hZG1pbi1zaGVsbC5pby9pZHRhL1N1Ym1vZGVsVGVtcGxhdGUvRGlnaXRhbE5hbWVwbGF0ZS8zLzA-Markings[0].MarkingFile-Schwindegg.png',
idShort: 'MarkingFile',
};

const fileName = resolveAttachmentFilename(file, 0, 'image/png');

expect(fileName).toBe(
'aHR0cHM6Ly9hZG1pbi1zaGVsbC5pby9pZHRhL1N1Ym1vZGVsVGVtcGxhdGUvRGlnaXRhbE5hbWVwbGF0ZS8zLzA-Markings-0-.MarkingFile-Schwindegg.png'
);
});

it('falls back to sanitized idShort and extension when value has no filename extension', () => {
const file = {
value: 'urn:example:attachment',
idShort: 'Manual File',
};

const fileName = resolveAttachmentFilename(file, 1, 'application/pdf');

expect(fileName).toBe('Manual-File-2.pdf');
});

it('falls back to generic file prefix when idShort is empty', () => {
const file = {
value: 'urn:example:attachment',
idShort: '',
};

const fileName = resolveAttachmentFilename(file, 2, 'image/png');

expect(fileName).toBe('file-3-3.png');
});

it('treats same-origin absolute thumbnail URLs as internal', () => {
const result = isCrossOriginHttpUrl(
'http://localhost:9081/shells/encoded/asset-information/thumbnail',
'http://localhost:9081/shells/encoded'
);

expect(result).toBe(false);
});

it('treats cross-origin absolute thumbnail URLs as external', () => {
const result = isCrossOriginHttpUrl(
'https://evil.example/thumbnail.png',
'http://localhost:9081/shells/encoded'
);

expect(result).toBe(true);
});

it('treats relative thumbnail URLs as internal when resolved against same backend origin', () => {
const result = isCrossOriginHttpUrl(
'/shells/encoded/asset-information/thumbnail',
'http://localhost:9081/shells/encoded'
);

expect(result).toBe(false);
});

it('treats http(s) thumbnails as external if reference origin cannot be determined', () => {
const result = isCrossOriginHttpUrl('https://example.com/thumbnail.png', 'not-a-valid-reference-url');

expect(result).toBe(true);
});
});
Loading
Loading