/*******************************************************************************
 * Copyright (c) 2000, 2006 IBM Corporation and others.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 * 
 * Contributors:
 *     IBM Corporation - initial API and implementation
 *******************************************************************************/
package org.eclipse.core.internal.runtime.auth;

import java.io.*;
import java.net.URL;
import java.util.*;
import org.eclipse.core.runtime.*;
import org.eclipse.osgi.util.NLS;

/**
 * A database that remembers information, such as user-names and
 * passwords.  The information is stored in memory and can be saved
 * to disk in an encrypted format.  While the API is phrased in terms of
 * URLs, realms and authentication schemes, not all of these must have
 * significant values.  For example, if "realm" is not relevant to a
 * particular application, it can be left blank (though not
 * <code>null</code>).
 */
public class AuthorizationDatabase {
	public static final String PI_RUNTIME_AUTH = "org.eclipse.core.runtime.auth.compatibility"; //$NON-NLS-1$

	/**
	 * Status code constant (value 4) indicating the platform could not read
	 * some of its metadata.
	 */
	public static final int FAILED_READ_METADATA = 4;

	/**
	 * Status code constant (value 5) indicating the platform could not write
	 * some of its metadata.
	 */
	public static final int FAILED_WRITE_METADATA = 5;

	/**
	 * Version number for the format of the key-ring file.
	 */
	private static final int KEYRING_FILE_VERSION = 1;

	/**
	 * A nested hashtable that stores authorization information. The
	 * table maps server URLs to realms to authentication schemes to
	 * authorization information.
	 */
	private Hashtable authorizationInfo = new Hashtable(5);

	/**
	 * A hashtable mapping resource URLs to realms.
	 */
	private Hashtable protectionSpace = new Hashtable(5);

	private File file = null;
	private String password = null;
	private boolean needsSaving = true;

	/**
	 * Creates a new authorization database whose data cannot be saved to
	 * disk.
	 */
	public AuthorizationDatabase() {
		super();
	}

	/**
	 * Creates a new authorization database, or opens an existing one, whose
	 * data is, or can be, saved to a file with the given filename. A
	 * password must be given to create a new database and an existing
	 * database is opened by supplying the password that was given to create
	 * it.
	 *
	 * @param filename the location of the database on disk. For example,
	 *		"c:/temp/database"
	 * @param password the password to access the database. For example,
	 *		"secret"
	 * @exception CoreException if there are problems creating the database.
	 *		Reasons include:
	 * <ul>
	 * <li>The database could not be opened because the wrong password was given.
	 * <li>The database could not be opened because the specified file is corrupt.
	 * </ul>
	 */
	public AuthorizationDatabase(String filename, String password) throws CoreException {
		Assert.isNotNull(filename);
		Assert.isNotNull(password);
		this.password = password;
		file = new File(filename);
		load();
	}

	/**
	 * Adds the given authorization information to the database. The
	 * information is relevant for the specified protection space and the
	 * given authorization scheme. The protection space is defined by the
	 * combination of the given server URL and realm. The authorization 
	 * scheme determines what the authorization information contains and how 
	 * it should be used. The authorization information is a <code>Map</code> 
	 * of <code>String</code> to <code>String</code> and typically
	 * contain information such as usernames and passwords.
	 *
	 * @param serverUrl the URL identifying the server for this authorization
	 *		information. For example, "http://www.hostname.com/".
	 * @param realm the subsection of the given server to which this
	 *		authorization information applies.  For example,
	 *		"realm1@hostname.com" or "" for no realm.
	 * @param authScheme the scheme for which this authorization information
	 *		applies. For example, "Basic" or "" for no authorization scheme
	 * @param info a <code>Map</code> containing authorization information 
	 *		such as usernames and passwords
	 */
	public void addAuthorizationInfo(URL serverUrl, String realm, String authScheme, Map info) {
		Assert.isNotNull(serverUrl);
		Assert.isNotNull(realm);
		Assert.isNotNull(authScheme);
		Assert.isNotNull(info);

		String url = serverUrl.toString();
		Hashtable realmToAuthScheme = (Hashtable) authorizationInfo.get(url);
		if (realmToAuthScheme == null) {
			realmToAuthScheme = new Hashtable(5);
			authorizationInfo.put(url, realmToAuthScheme);
		}

		Hashtable authSchemeToInfo = (Hashtable) realmToAuthScheme.get(realm);
		if (authSchemeToInfo == null) {
			authSchemeToInfo = new Hashtable(5);
			realmToAuthScheme.put(realm, authSchemeToInfo);
		}

		authSchemeToInfo.put(authScheme.toLowerCase(), info);
		needsSaving = true;
	}

	/**
	 * Adds the specified resource to the protection space specified by the
	 * given realm. All resources at or deeper than the depth of the last
	 * symbolic element in the path of the given resource URL are assumed to
	 * be in the same protection space.
	 *
	 * @param resourceUrl the URL identifying the resources to be added to
	 *		the specified protection space. For example,
	 *		"http://www.hostname.com/folder/".
	 * @param realm the name of the protection space. For example,
	 *		"realm1@hostname.com"
	 */
	public void addProtectionSpace(URL resourceUrl, String realm) {
		Assert.isNotNull(resourceUrl);
		Assert.isNotNull(realm);

		if (!resourceUrl.getFile().endsWith("/")) { //$NON-NLS-1$
			resourceUrl = URLTool.getParent(resourceUrl);
		}

		String oldRealm = getProtectionSpace(resourceUrl);
		if (oldRealm != null && oldRealm.equals(realm)) {
			return;
		}

		String url1 = resourceUrl.toString();
		Enumeration urls = protectionSpace.keys();
		while (urls.hasMoreElements()) {
			String url2 = (String) urls.nextElement();
			if (url1.startsWith(url2) || url2.startsWith(url1)) {
				protectionSpace.remove(url2);
				break;
			}
		}

		protectionSpace.put(url1, realm);
		needsSaving = true;
	}

	/**
	 * Removes the authorization information for the specified protection
	 * space and given authorization scheme. The protection space is defined
	 * by the given server URL and realm.
	 *
	 * @param serverUrl the URL identifying the server to remove the
	 *		authorization information for. For example,
	 *		"http://www.hostname.com/".
	 * @param realm the subsection of the given server to remove the
	 *		authorization information for. For example,
	 *		"realm1@hostname.com" or "" for no realm.
	 * @param authScheme the scheme for which the authorization information
	 *		to remove applies. For example, "Basic" or "" for no
	 *		authorization scheme.
	 */
	public void flushAuthorizationInfo(URL serverUrl, String realm, String authScheme) {
		Hashtable realmToAuthScheme = (Hashtable) authorizationInfo.get(serverUrl.toString());

		if (realmToAuthScheme == null) {
			return;
		}

		Hashtable authSchemeToInfo = (Hashtable) realmToAuthScheme.get(realm);

		if (authSchemeToInfo == null) {
			return;
		}

		authSchemeToInfo.remove(authScheme.toLowerCase());

		needsSaving = true;
	}

	/**
	 * Returns the authorization information for the specified protection
	 * space and given authorization scheme. The protection space is defined
	 * by the given server URL and realm. Returns <code>null</code> if no
	 * such information exists.
	 *
	 * @param serverUrl the URL identifying the server for the authorization
	 *		information. For example, "http://www.hostname.com/".
	 * @param realm the subsection of the given server to which the
	 *		authorization information applies.  For example,
	 *		"realm1@hostname.com" or "" for no realm.
	 * @param authScheme the scheme for which the authorization information
	 *		applies. For example, "Basic" or "" for no authorization scheme
	 * @return the authorization information for the specified protection
	 *		space and given authorization scheme, or <code>null</code> if no
	 *		such information exists
	 */
	public Map getAuthorizationInfo(URL serverUrl, String realm, String authScheme) {
		Hashtable realmToAuthScheme = (Hashtable) authorizationInfo.get(serverUrl.toString());
		if (realmToAuthScheme == null) {
			return null;
		}

		Hashtable authSchemeToInfo = (Hashtable) realmToAuthScheme.get(realm);
		if (authSchemeToInfo == null) {
			return null;
		}

		return (Map) authSchemeToInfo.get(authScheme.toLowerCase());
	}

	/**
	 * Returns the protection space (realm) for the specified resource, or
	 * <code>null</code> if the realm is unknown.
	 *
	 * @param resourceUrl the URL of the resource whose protection space is
	 *		returned. For example, "http://www.hostname.com/folder/".
	 * @return the protection space (realm) for the specified resource, or
	 *		<code>null</code> if the realm is unknown
	 */
	public String getProtectionSpace(URL resourceUrl) {
		while (resourceUrl != null) {
			String realm = (String) protectionSpace.get(resourceUrl.toString());
			if (realm != null) {
				return realm;
			}
			resourceUrl = URLTool.getParent(resourceUrl);
		}

		return null;
	}

	private void load() throws CoreException {
		if (file == null)
			return;
		if (!file.exists()) {
			save();
			return;
		}
		try {
			InputStream input = new FileInputStream(file);
			try {
				load(input);
			} finally {
				input.close();
			}
		} catch (IOException e) {
			throw new CoreException(new Status(IStatus.ERROR, PI_RUNTIME_AUTH, FAILED_READ_METADATA, NLS.bind(Messages.meta_unableToReadAuthorization, file), e));
		} catch (ClassNotFoundException e) {
			throw new CoreException(new Status(IStatus.ERROR, PI_RUNTIME_AUTH, FAILED_READ_METADATA, NLS.bind(Messages.meta_unableToReadAuthorization, file), e));
		}
	}

	private void load(InputStream is) throws IOException, ClassNotFoundException, CoreException {
		//try to read the file version number. Pre 2.0 versions had no number
		int version = is.read();
		if (version == KEYRING_FILE_VERSION) {
			//read the authorization data
			CipherInputStream cis = new CipherInputStream(is, password);
			ObjectInputStream ois = new ObjectInputStream(cis);
			try {
				authorizationInfo = (Hashtable) ois.readObject();
				protectionSpace = (Hashtable) ois.readObject();
			} finally {
				ois.close();
			}
		} else {
			//the format has changed, just log a warning
			Activator.log(new Status(IStatus.WARNING, PI_RUNTIME_AUTH, FAILED_READ_METADATA, Messages.meta_authFormatChanged, null));
			//close the stream and save a new file in the correct format
			try {
				is.close();
			} catch (IOException e) {
				//ignore failure to close
			}
			needsSaving = true;
			save();
		}
	}

	/**
	 * Saves the authorization database to disk.
	 */
	public void save() throws CoreException {
		if (!needsSaving || file == null)
			return;
		try {
			file.delete();
			if ((!file.getParentFile().exists() && !file.getParentFile().mkdirs()) || !canWrite(file.getParentFile()))
				throw new CoreException(new Status(IStatus.ERROR, PI_RUNTIME_AUTH, FAILED_WRITE_METADATA, NLS.bind(Messages.meta_unableToWriteAuthorization, file), null));
			file.createNewFile();
			FileOutputStream out = new FileOutputStream(file);
			try {
				save(out);
			} finally {
				out.close();
			}
		} catch (IOException e) {
			throw new CoreException(new Status(IStatus.ERROR, PI_RUNTIME_AUTH, FAILED_WRITE_METADATA, NLS.bind(Messages.meta_unableToWriteAuthorization, file), e));
		}
		needsSaving = false;
	}

	private static boolean canWrite(File installDir) {
		if (!installDir.canWrite())
			return false;

		if (!installDir.isDirectory())
			return false;

		File fileTest = null;
		try {
			fileTest = File.createTempFile("writtableArea", null, installDir); //$NON-NLS-1$
		} catch (IOException e) {
			// If an exception occurred while trying to create the file, it means that it is not writable
			return false;
		} finally {
			if (fileTest != null)
				fileTest.delete();
		}
		return true;
	}

	private void save(FileOutputStream os) throws IOException {
		//write the version number
		os.write(KEYRING_FILE_VERSION);

		CipherOutputStream cos = new CipherOutputStream(os, password);
		ObjectOutputStream oos = new ObjectOutputStream(cos);
		//write the data
		try {
			oos.writeObject(authorizationInfo);
			oos.writeObject(protectionSpace);
			os.flush();
			os.getFD().sync();
		} finally {
			oos.close();
		}
	}

	/**
	 * Sets the password to use for accessing this database.  If the database
	 * is subsequently saved, this new password is used.
	 */
	public boolean setPassword(String oldValue, String newValue) {
		if (!oldValue.equals(password))
			return false;
		password = newValue;
		needsSaving = true;
		return true;
	}
}
